Skip to content

Commit 1383231

Browse files
authored
Add AuthenticatedSchemes option (#1047)
1 parent a85c0d2 commit 1383231

File tree

8 files changed

+230
-4
lines changed

8 files changed

+230
-4
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,28 @@ Note that `InvokeAsync` will execute even if the protocol is disabled in the opt
482482
disabling `HandleGet` or similar; `HandleAuthorizeAsync` and `HandleAuthorizeWebSocketConnectionAsync`
483483
will not.
484484

485+
#### Authentication schemes
486+
487+
By default the role and policy requirements are validated against the current user as defined by
488+
`HttpContext.User`. This is typically set by ASP.NET Core's authentication middleware and is based
489+
on the default authentication scheme set during the call to `AddAuthentication` in `Startup.cs`.
490+
You may override this behavior by specifying a different authentication scheme via the `AuthenticationSchemes`
491+
option. For instance, if you wish to authenticate using JWT authentication when Cookie authentication is
492+
the default, you may specify the scheme as follows:
493+
494+
```csharp
495+
app.UseGraphQL("/graphql", config =>
496+
{
497+
// specify a specific authentication scheme to use
498+
config.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
499+
});
500+
```
501+
502+
This will overwrite the `HttpContext.User` property when handling GraphQL requests, which will in turn
503+
set the `IResolveFieldContext.User` property to the same value (unless being overridden via an
504+
`IWebSocketAuthenticationService` implementation as shown above). So both endpoint authorization and
505+
field authorization will perform role and policy checks against the same authentication scheme.
506+
485507
### UI configuration
486508

487509
There are four UI middleware projects included; Altair, GraphiQL, Playground and Voyager.

src/Transports.AspNetCore/GraphQLHttpMiddleware.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#pragma warning disable CA1716 // Identifiers should not match keywords
22

3+
using Microsoft.AspNetCore.Authentication;
34
using Microsoft.Extensions.Primitives;
45
using MediaTypeHeaderValueMs = Microsoft.Net.Http.Headers.MediaTypeHeaderValue;
56

@@ -270,6 +271,8 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne
270271
/// </summary>
271272
protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context, RequestDelegate next)
272273
{
274+
await SetHttpContextUserAsync(context);
275+
273276
var success = await AuthorizationHelper.AuthorizeAsync(
274277
new AuthorizationParameters<(GraphQLHttpMiddleware Middleware, HttpContext Context, RequestDelegate Next)>(
275278
context,
@@ -282,6 +285,26 @@ protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context
282285
return !success;
283286
}
284287

288+
/// <summary>
289+
/// If any authentication schemes are defined, set the <see cref="HttpContext.User"/> property.
290+
/// </summary>
291+
private async ValueTask SetHttpContextUserAsync(HttpContext context)
292+
{
293+
if (_options.AuthenticationSchemes.Count > 0)
294+
{
295+
ClaimsPrincipal? newPrincipal = null;
296+
foreach (var scheme in _options.AuthenticationSchemes)
297+
{
298+
var result = await context.AuthenticateAsync(scheme);
299+
if (result != null && result.Succeeded)
300+
{
301+
newPrincipal = SecurityHelper.MergeUserPrincipal(newPrincipal, result.Principal);
302+
}
303+
}
304+
context.User = newPrincipal ?? new ClaimsPrincipal(new ClaimsIdentity());
305+
}
306+
}
307+
285308
/// <summary>
286309
/// Perform authorization, if required, and return <see langword="true"/> if the
287310
/// request was handled (typically by returning an error message). If <see langword="false"/>
@@ -291,8 +314,11 @@ protected virtual async ValueTask<bool> HandleAuthorizeAsync(HttpContext context
291314
/// the WebSocket connection during the ConnectionInit message. Authorization checks for
292315
/// WebSocket connections occur then, after authorization has taken place.
293316
/// </summary>
294-
protected virtual ValueTask<bool> HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next)
295-
=> new(false);
317+
protected virtual async ValueTask<bool> HandleAuthorizeWebSocketConnectionAsync(HttpContext context, RequestDelegate next)
318+
{
319+
await SetHttpContextUserAsync(context);
320+
return false;
321+
}
296322

297323
/// <summary>
298324
/// Handles a single GraphQL request.

src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
8484
/// </summary>
8585
public bool ReadExtensionsFromQueryString { get; set; } = true;
8686

87+
/// <summary>
88+
/// Gets or sets a list of the authentication schemes the authentication requirements are evaluated against.
89+
/// When no schemes are specified, the default authentication scheme is used.
90+
/// </summary>
91+
public List<string> AuthenticationSchemes { get; set; } = new();
92+
8793
/// <inheritdoc/>
8894
/// <remarks>
8995
/// HTTP requests return <c>401 Forbidden</c> when the request is not authenticated.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// source: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/SecurityHelper/SecurityHelper.cs
2+
// permalink: https://github.com/dotnet/aspnetcore/blob/8b2fd3f7a3b3e18afc6f63c4a494cc733dcced64/src/Shared/SecurityHelper/SecurityHelper.cs
3+
// retrieved: 2023-07-05
4+
5+
// Licensed to the .NET Foundation under one or more agreements.
6+
// The .NET Foundation licenses this file to you under the MIT license.
7+
8+
/*
9+
10+
The MIT License (MIT)
11+
12+
Copyright (c) .NET Foundation and Contributors
13+
14+
All rights reserved.
15+
16+
Permission is hereby granted, free of charge, to any person obtaining a copy
17+
of this software and associated documentation files (the "Software"), to deal
18+
in the Software without restriction, including without limitation the rights
19+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20+
copies of the Software, and to permit persons to whom the Software is
21+
furnished to do so, subject to the following conditions:
22+
23+
The above copyright notice and this permission notice shall be included in all
24+
copies or substantial portions of the Software.
25+
26+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32+
SOFTWARE.
33+
34+
*/
35+
36+
namespace GraphQL.Server.Transports.AspNetCore;
37+
38+
/// <summary>
39+
/// Helper code used when implementing authentication middleware
40+
/// </summary>
41+
internal static class SecurityHelper
42+
{
43+
/// <summary>
44+
/// Add all ClaimsIdentities from an additional ClaimPrincipal to the ClaimsPrincipal
45+
/// Merges a new claims principal, placing all new identities first, and eliminating
46+
/// any empty unauthenticated identities from context.User
47+
/// </summary>
48+
/// <param name="existingPrincipal">The <see cref="ClaimsPrincipal"/> containing existing <see cref="ClaimsIdentity"/>.</param>
49+
/// <param name="additionalPrincipal">The <see cref="ClaimsPrincipal"/> containing <see cref="ClaimsIdentity"/> to be added.</param>
50+
public static ClaimsPrincipal MergeUserPrincipal(ClaimsPrincipal? existingPrincipal, ClaimsPrincipal? additionalPrincipal)
51+
{
52+
// For the first principal, just use the new principal rather than copying it
53+
if (existingPrincipal == null && additionalPrincipal != null)
54+
{
55+
return additionalPrincipal;
56+
}
57+
58+
var newPrincipal = new ClaimsPrincipal();
59+
60+
// New principal identities go first
61+
if (additionalPrincipal != null)
62+
{
63+
newPrincipal.AddIdentities(additionalPrincipal.Identities);
64+
}
65+
66+
// Then add any existing non empty or authenticated identities
67+
if (existingPrincipal != null)
68+
{
69+
newPrincipal.AddIdentities(existingPrincipal.Identities.Where(i => i.IsAuthenticated || i.Claims.Any()));
70+
}
71+
return newPrincipal;
72+
}
73+
}

tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ namespace GraphQL.Server.Transports.AspNetCore
111111
public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions
112112
{
113113
public GraphQLHttpMiddlewareOptions() { }
114+
public System.Collections.Generic.List<string> AuthenticationSchemes { get; set; }
114115
public bool AuthorizationRequired { get; set; }
115116
public string? AuthorizedPolicy { get; set; }
116117
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }

tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ namespace GraphQL.Server.Transports.AspNetCore
118118
public class GraphQLHttpMiddlewareOptions : GraphQL.Server.Transports.AspNetCore.IAuthorizationOptions
119119
{
120120
public GraphQLHttpMiddlewareOptions() { }
121+
public System.Collections.Generic.List<string> AuthenticationSchemes { get; set; }
121122
public bool AuthorizationRequired { get; set; }
122123
public string? AuthorizedPolicy { get; set; }
123124
public System.Collections.Generic.List<string> AuthorizedRoles { get; set; }

tests/Transports.AspNetCore.Tests/Middleware/AuthorizationTests.cs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Security.Claims;
44
using GraphQL.Execution;
55
using GraphQL.Server.Transports.AspNetCore.Errors;
6+
using Microsoft.AspNetCore.Authentication.Cookies;
67
using Microsoft.AspNetCore.Authentication.JwtBearer;
78

89
namespace Tests.Middleware;
@@ -11,9 +12,14 @@ public class AuthorizationTests : IDisposable
1112
{
1213
private GraphQLHttpMiddlewareOptions _options = null!;
1314
private bool _enableCustomErrorInfoProvider;
14-
private readonly TestServer _server;
15+
private TestServer _server;
1516

1617
public AuthorizationTests()
18+
{
19+
_server = CreateServer();
20+
}
21+
22+
private TestServer CreateServer(Action<IServiceCollection>? configureServices = null)
1723
{
1824
var hostBuilder = new WebHostBuilder();
1925
hostBuilder.ConfigureServices(services =>
@@ -46,6 +52,7 @@ public AuthorizationTests()
4652
#if NETCOREAPP2_1 || NET48
4753
services.AddHostApplicationLifetime();
4854
#endif
55+
configureServices?.Invoke(services);
4956
});
5057
hostBuilder.Configure(app =>
5158
{
@@ -59,7 +66,7 @@ public AuthorizationTests()
5966
_options = opts;
6067
});
6168
});
62-
_server = new TestServer(hostBuilder);
69+
return new TestServer(hostBuilder);
6370
}
6471

6572
public void Dispose() => _server.Dispose();
@@ -270,6 +277,95 @@ public async Task Authorized_Policy()
270277
actual.ShouldBe("""{"data":{"__typename":"Query"}}""");
271278
}
272279

280+
[Fact]
281+
public async Task NotAuthorized_WrongScheme()
282+
{
283+
_server.Dispose();
284+
_server = CreateServer(services =>
285+
{
286+
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication
287+
});
288+
_options.AuthorizationRequired = true;
289+
using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme)
290+
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
291+
var actual = await response.Content.ReadAsStringAsync();
292+
actual.ShouldBe(@"{""errors"":[{""message"":""Access denied for schema."",""extensions"":{""code"":""ACCESS_DENIED"",""codes"":[""ACCESS_DENIED""]}}]}");
293+
}
294+
295+
[Fact]
296+
public async Task NotAuthorized_WrongScheme_2()
297+
{
298+
_server.Dispose();
299+
_server = CreateServer(services =>
300+
{
301+
services.AddAuthentication().AddCookie(); // add Cookie authentication
302+
});
303+
_options.AuthorizationRequired = true;
304+
_options.AuthenticationSchemes.Add(CookieAuthenticationDefaults.AuthenticationScheme); // change authentication scheme for GraphQL requests to Cookie (which is not used by the test client)
305+
using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme)
306+
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
307+
var actual = await response.Content.ReadAsStringAsync();
308+
actual.ShouldBe(@"{""errors"":[{""message"":""Access denied for schema."",""extensions"":{""code"":""ACCESS_DENIED"",""codes"":[""ACCESS_DENIED""]}}]}");
309+
}
310+
311+
[Fact]
312+
public async Task NotAuthorized_WrongScheme_VerifyUser()
313+
{
314+
bool validatedUser = false;
315+
_server.Dispose();
316+
_server = CreateServer(services =>
317+
{
318+
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication
319+
services.AddGraphQL(b => b
320+
.ConfigureExecutionOptions(opts =>
321+
{
322+
opts.User.ShouldNotBeNull().Identity.ShouldNotBeNull().IsAuthenticated.ShouldBeFalse();
323+
validatedUser = true;
324+
}));
325+
});
326+
_options.AuthorizationRequired = false; // disable authorization requirements; we just want to verify that an anonymous user is passed to the execution options
327+
using var response = await PostQueryAsync("{ __typename }", true); // send an authenticated request (with JWT bearer scheme)
328+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
329+
var actual = await response.Content.ReadAsStringAsync();
330+
actual.ShouldBe(@"{""data"":{""__typename"":""Query""}}");
331+
validatedUser.ShouldBeTrue();
332+
}
333+
334+
[Fact]
335+
public async Task Authorized_DifferentScheme()
336+
{
337+
bool validatedUser = false;
338+
_server.Dispose();
339+
_server = CreateServer(services =>
340+
{
341+
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // change default scheme to Cookie authentication
342+
services.AddGraphQL(b => b.ConfigureExecutionOptions(opts =>
343+
{
344+
opts.User.ShouldNotBeNull().Identity.ShouldNotBeNull().IsAuthenticated.ShouldBeTrue();
345+
validatedUser = true;
346+
}));
347+
});
348+
_options.AuthorizationRequired = true;
349+
_options.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
350+
using var response = await PostQueryAsync("{ __typename }", true);
351+
response.StatusCode.ShouldBe(HttpStatusCode.OK);
352+
var actual = await response.Content.ReadAsStringAsync();
353+
actual.ShouldBe(@"{""data"":{""__typename"":""Query""}}");
354+
validatedUser.ShouldBeTrue();
355+
}
356+
357+
[Fact]
358+
public void SecurityHelperTests()
359+
{
360+
SecurityHelper.MergeUserPrincipal(null, null).ShouldNotBeNull().Identity.ShouldBeNull(); // Note that ASP.NET Core does not return null for anonymous user
361+
var principal1 = new ClaimsPrincipal(new ClaimsIdentity()); // empty identity for primary identity (default for ASP.NET Core)
362+
SecurityHelper.MergeUserPrincipal(null, principal1).ShouldBe(principal1);
363+
var principal2 = new ClaimsPrincipal(new ClaimsIdentity("test1")); // non-empty identity for secondary identity
364+
SecurityHelper.MergeUserPrincipal(principal1, principal2).Identities.ShouldHaveSingleItem().AuthenticationType.ShouldBe("test1");
365+
var principal3 = new ClaimsPrincipal(new ClaimsIdentity("test2")); // merge two non-empty identities together
366+
SecurityHelper.MergeUserPrincipal(principal2, principal3).Identities.Select(x => x.AuthenticationType).ShouldBe(new[] { "test2", "test1" }); // last one wins
367+
}
368+
273369
private class CustomErrorInfoProvider : ErrorInfoProvider
274370
{
275371
private readonly AuthorizationTests _authorizationTests;

tests/Transports.AspNetCore.Tests/Transports.AspNetCore.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

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

2021
<ItemGroup>

0 commit comments

Comments
 (0)