Skip to content

Commit cba251c

Browse files
authored
Add JWT Bearer authentication package (#82)
* Add JWT Bearer authentication package * update * update * update * fix bug * Update * Fix default scheme * Add tests * Update * Update * Update
1 parent 72dfdf0 commit cba251c

20 files changed

+756
-16
lines changed

GraphQL.AspNetCore3.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CorsSample", "src\Samples\C
5151
EndProject
5252
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Net48Sample", "src\Samples\Net48Sample\Net48Sample.csproj", "{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}"
5353
EndProject
54+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL.AspNetCore3.JwtBearer", "src\GraphQL.AspNetCore3.JwtBearer\GraphQL.AspNetCore3.JwtBearer.csproj", "{7FDCD730-A321-4147-998F-0F26549B0A39}"
55+
EndProject
5456
Global
5557
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5658
Debug|Any CPU = Debug|Any CPU
@@ -105,6 +107,10 @@ Global
105107
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
106108
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
107109
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Release|Any CPU.Build.0 = Release|Any CPU
110+
{7FDCD730-A321-4147-998F-0F26549B0A39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
111+
{7FDCD730-A321-4147-998F-0F26549B0A39}.Debug|Any CPU.Build.0 = Debug|Any CPU
112+
{7FDCD730-A321-4147-998F-0F26549B0A39}.Release|Any CPU.ActiveCfg = Release|Any CPU
113+
{7FDCD730-A321-4147-998F-0F26549B0A39}.Release|Any CPU.Build.0 = Release|Any CPU
108114
EndGlobalSection
109115
GlobalSection(SolutionProperties) = preSolution
110116
HideSolutionNode = FALSE

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,38 @@ Note that `InvokeAsync` will execute even if the protocol is disabled in the opt
422422
disabling `HandleGet` or similar; `HandleAuthorizeAsync` and `HandleAuthorizeWebSocketConnectionAsync`
423423
will not.
424424

425+
JWT Bearer authentication is provided by the `GraphQL.AspNetCore3.JwtBearer` package.
426+
Like the above sample, it will look for an "Authorization" entry that starts with "Bearer "
427+
and validate the token using the configured ASP.Net Core JWT Bearer authentication handler.
428+
Configure it using the `AddJwtBearerAuthentication` extension method as shown
429+
in the example below:
430+
431+
```csharp
432+
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
433+
.AddJwtBearer();
434+
435+
builder.Services.AddGraphQL(b => b
436+
.AddAutoSchema<Query>()
437+
.AddSystemTextJson()
438+
.AddAuthorizationRule()
439+
.AddJwtBearerAuthentication()
440+
);
441+
442+
app.UseGraphQL("/graphql", config =>
443+
{
444+
// require that the user be authenticated
445+
config.AuthorizationRequired = true;
446+
});
447+
```
448+
449+
Please note:
450+
451+
- If JWT Bearer is not the default authentication scheme, you will need to specify
452+
the authentication scheme to use for GraphQL requests. See 'Authentication schemes'
453+
below for more information.
454+
455+
- Events configured through `JwtBearerEvents` are not currently supported.
456+
425457
#### Authentication schemes
426458

427459
By default the role and policy requirements are validated against the current user as defined by

migration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Version history / migration notes
22

3+
## 7.0.0
4+
5+
GraphQL.AspNetCore3 v7 requires GraphQL.NET v8 or newer.
6+
7+
### New features
8+
9+
- Supports JWT WebSocket Authentication using the separately-provided `GraphQL.AspNetCore3.JwtBearer` package.
10+
- Inherits most options configured by the `Microsoft.AspNetCore.Authentication.JwtBearer` package.
11+
- Supports multiple authentication schemes, configurable via the `GraphQLHttpMiddlewareOptions.AuthenticationSchemes` property.
12+
- Defaults to attempting the `AuthenticationOptions.DefaultScheme` scheme if not specified.
13+
14+
### Breaking changes
15+
16+
- `AuthenticationSchemes` property added to `IAuthorizationOptions` interface.
17+
- `IWebSocketAuthenticationService.AuthenticateAsync` parameters refactored into an `AuthenticationRequest` class.
18+
319
## 6.0.0
420

521
GraphQL.AspNetCore3 v6 requires GraphQL.NET v8 or newer.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using GraphQL.AspNetCore3;
2+
using GraphQL.AspNetCore3.JwtBearer;
3+
using GraphQL.DI;
4+
5+
namespace GraphQL;
6+
7+
/// <summary>
8+
/// Extension methods for adding JWT bearer authentication to a GraphQL server for WebSocket communications.
9+
/// </summary>
10+
public static class AspNetCore3JwtBearerExtensions
11+
{
12+
/// <summary>
13+
/// Adds JWT bearer authentication to a GraphQL server for WebSocket communications.
14+
/// </summary>
15+
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder)
16+
{
17+
builder.AddWebSocketAuthentication<JwtWebSocketAuthenticationService>();
18+
return builder;
19+
}
20+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>netstandard2.0;netcoreapp2.1;netcoreapp3.1;net6.0;net8.0</TargetFrameworks>
5+
<Description>JWT Bearer authentication for GraphQL projects</Description>
6+
<IsPackable>true</IsPackable>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.*" Condition="'$(TargetFramework)' == 'net8.0'" />
11+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.*" Condition="'$(TargetFramework)' == 'net6.0'" />
12+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.*" Condition="'$(TargetFramework)' == 'netcoreapp3.1'" />
13+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.*" Condition="'$(TargetFramework)' == 'netcoreapp2.1'" />
14+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.*" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\GraphQL.AspNetCore3\GraphQL.AspNetCore3.csproj" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<Using Include="System.Text" />
23+
<Using Include="System.Threading" />
24+
<Using Include="System.Threading.Tasks" />
25+
</ItemGroup>
26+
27+
</Project>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Parts of this code file are based on the JwtBearerHandler class in the Microsoft.AspNetCore.Authentication.JwtBearer package found at:
2+
// https://github.com/dotnet/aspnetcore/blob/5493b413d1df3aaf00651bdf1cbd8135fa63f517/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs
3+
//
4+
// Those sections of code may be subject to the MIT license found at:
5+
// https://github.com/dotnet/aspnetcore/blob/5493b413d1df3aaf00651bdf1cbd8135fa63f517/LICENSE.txt
6+
7+
using System.Security.Claims;
8+
using GraphQL.AspNetCore3.WebSockets;
9+
using Microsoft.AspNetCore.Authentication;
10+
using Microsoft.AspNetCore.Authentication.JwtBearer;
11+
using Microsoft.AspNetCore.Http;
12+
using Microsoft.Extensions.DependencyInjection;
13+
using Microsoft.Extensions.Options;
14+
using Microsoft.IdentityModel.Tokens;
15+
16+
namespace GraphQL.AspNetCore3.JwtBearer;
17+
18+
/// <summary>
19+
/// Authenticates WebSocket connections via the 'payload' of the initialization packet.
20+
/// This is necessary because WebSocket connections initiated from the browser cannot
21+
/// authenticate via HTTP headers.
22+
/// <br/><br/>
23+
/// Notes:
24+
/// <list type="bullet">
25+
/// <item>This class is not used when authenticating over GET/POST.</item>
26+
/// <item>
27+
/// This class pulls the <see cref="JwtBearerOptions"/> instance registered by ASP.NET Core during the call to
28+
/// <see cref="JwtBearerExtensions.AddJwtBearer(AuthenticationBuilder, Action{JwtBearerOptions})">AddJwtBearer</see>
29+
/// for the default or configured authentication scheme and authenticates the token
30+
/// based on simplified logic used by <see cref="JwtBearerHandler"/>.
31+
/// </item>
32+
/// <item>
33+
/// The expected format of the payload is <c>{"Authorization":"Bearer TOKEN"}</c> where TOKEN is the JSON Web Token (JWT),
34+
/// mirroring the format of the 'Authorization' HTTP header.
35+
/// </item>
36+
/// <item>
37+
/// Events configured in <see cref="JwtBearerOptions.Events"/> are not raised by this implementation.
38+
/// </item>
39+
/// <item>
40+
/// Implementation does not call <see cref="Microsoft.Extensions.Logging.ILogger"/> to log authentication events.
41+
/// </item>
42+
/// </list>
43+
/// </summary>
44+
public class JwtWebSocketAuthenticationService : IWebSocketAuthenticationService
45+
{
46+
private readonly IGraphQLSerializer _graphQLSerializer;
47+
private readonly IOptionsMonitor<JwtBearerOptions> _jwtBearerOptionsMonitor;
48+
private readonly string[] _defaultAuthenticationSchemes;
49+
50+
/// <summary>
51+
/// Initializes a new instance of the <see cref="JwtWebSocketAuthenticationService"/> class.
52+
/// </summary>
53+
public JwtWebSocketAuthenticationService(IGraphQLSerializer graphQLSerializer, IOptionsMonitor<JwtBearerOptions> jwtBearerOptionsMonitor, IOptions<AuthenticationOptions> authenticationOptions)
54+
{
55+
_graphQLSerializer = graphQLSerializer;
56+
_jwtBearerOptionsMonitor = jwtBearerOptionsMonitor;
57+
var defaultAuthenticationScheme = authenticationOptions.Value.DefaultScheme;
58+
_defaultAuthenticationSchemes = defaultAuthenticationScheme != null ? [defaultAuthenticationScheme] : [];
59+
}
60+
61+
/// <inheritdoc/>
62+
public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
63+
{
64+
var connection = authenticationRequest.Connection;
65+
var operationMessage = authenticationRequest.OperationMessage;
66+
var schemes = authenticationRequest.AuthenticationSchemes.Any() ? authenticationRequest.AuthenticationSchemes : _defaultAuthenticationSchemes;
67+
try {
68+
// for connections authenticated via HTTP headers, no need to reauthenticate
69+
if (connection.HttpContext.User.Identity?.IsAuthenticated ?? false)
70+
return;
71+
72+
// attempt to read the 'Authorization' key from the payload object and verify it contains "Bearer XXXXXXXX"
73+
var authPayload = _graphQLSerializer.ReadNode<AuthPayload>(operationMessage.Payload);
74+
if (authPayload != null && authPayload.Authorization != null && authPayload.Authorization.StartsWith("Bearer ", StringComparison.Ordinal)) {
75+
// pull the token from the value
76+
var token = authPayload.Authorization.Substring(7);
77+
78+
// try to authenticate with each of the configured authentication schemes
79+
foreach (var scheme in schemes) {
80+
var options = _jwtBearerOptionsMonitor.Get(scheme);
81+
82+
// follow logic simplified from JwtBearerHandler.HandleAuthenticateAsync, as follows:
83+
var tokenValidationParameters = await SetupTokenValidationParametersAsync(options, connection.HttpContext).ConfigureAwait(false);
84+
#if NET8_0_OR_GREATER
85+
if (!options.UseSecurityTokenValidators) {
86+
foreach (var tokenHandler in options.TokenHandlers) {
87+
try {
88+
var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tokenValidationParameters).ConfigureAwait(false);
89+
if (tokenValidationResult.IsValid) {
90+
var principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity);
91+
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
92+
connection.HttpContext.User = principal;
93+
return;
94+
}
95+
} catch {
96+
// no errors during authentication should throw an exception
97+
// specifically, attempting to validate an invalid JWT token may result in an exception
98+
}
99+
}
100+
} else {
101+
#else
102+
{
103+
#endif
104+
#pragma warning disable CS0618 // Type or member is obsolete
105+
foreach (var validator in options.SecurityTokenValidators) {
106+
if (validator.CanReadToken(token)) {
107+
try {
108+
var principal = validator.ValidateToken(token, tokenValidationParameters, out _);
109+
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
110+
connection.HttpContext.User = principal;
111+
return;
112+
} catch {
113+
// no errors during authentication should throw an exception
114+
// specifically, attempting to validate an invalid JWT token will result in an exception
115+
}
116+
}
117+
}
118+
#pragma warning restore CS0618 // Type or member is obsolete
119+
}
120+
}
121+
}
122+
} catch {
123+
// no errors during authentication should throw an exception
124+
// specifically, parsing invalid JSON will result in an exception
125+
}
126+
}
127+
128+
private static async ValueTask<TokenValidationParameters> SetupTokenValidationParametersAsync(JwtBearerOptions options, HttpContext httpContext)
129+
{
130+
// Clone to avoid cross request race conditions for updated configurations.
131+
var tokenValidationParameters = options.TokenValidationParameters.Clone();
132+
133+
#if NET8_0_OR_GREATER
134+
if (options.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) {
135+
tokenValidationParameters.ConfigurationManager = baseConfigurationManager;
136+
} else {
137+
#else
138+
{
139+
#endif
140+
if (options.ConfigurationManager != null) {
141+
// GetConfigurationAsync has a time interval that must pass before new http request will be issued.
142+
var configuration = await options.ConfigurationManager.GetConfigurationAsync(httpContext.RequestAborted).ConfigureAwait(false);
143+
var issuers = new[] { configuration.Issuer };
144+
tokenValidationParameters.ValidIssuers = (tokenValidationParameters.ValidIssuers == null ? issuers : tokenValidationParameters.ValidIssuers.Concat(issuers));
145+
tokenValidationParameters.IssuerSigningKeys = (tokenValidationParameters.IssuerSigningKeys == null ? configuration.SigningKeys : tokenValidationParameters.IssuerSigningKeys.Concat(configuration.SigningKeys));
146+
}
147+
}
148+
149+
return tokenValidationParameters;
150+
}
151+
152+
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
153+
public sealed class AuthPayload
154+
{
155+
public string? Authorization { get; set; }
156+
}
157+
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
158+
}

src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System.Security.Claims;
66
using Microsoft.AspNetCore.Authentication;
77
using Microsoft.AspNetCore.Authorization;
8-
using static System.Net.Mime.MediaTypeNames;
98

109
namespace GraphQL.AspNetCore3;
1110

src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
9999
/// </summary>
100100
public List<string> AuthenticationSchemes { get; set; } = new();
101101

102+
IEnumerable<string> IAuthorizationOptions.AuthenticationSchemes => AuthenticationSchemes;
103+
102104
/// <inheritdoc/>
103105
/// <remarks>
104106
/// HTTP requests return <c>401 Forbidden</c> when the request is not authenticated.

src/GraphQL.AspNetCore3/IAuthorizationOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ namespace GraphQL.AspNetCore3;
99
/// </summary>
1010
public interface IAuthorizationOptions
1111
{
12+
/// <summary>
13+
/// Gets a list of the authentication schemes the authentication requirements are evaluated against.
14+
/// When no schemes are specified, the default authentication scheme is used.
15+
/// </summary>
16+
IEnumerable<string> AuthenticationSchemes { get; }
17+
1218
/// <summary>
1319
/// If set, requires that <see cref="IIdentity.IsAuthenticated"/> return <see langword="true"/>
1420
/// for the user within <see cref="HttpContext.User"/>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace GraphQL.AspNetCore3.WebSockets;
2+
3+
/// <summary>
4+
/// Represents an authentication request within the GraphQL ASP.NET Core WebSocket context.
5+
/// </summary>
6+
public class AuthenticationRequest
7+
{
8+
/// <summary>
9+
/// Gets the WebSocket connection associated with the authentication request.
10+
/// </summary>
11+
/// <value>
12+
/// An instance of <see cref="IWebSocketConnection"/> representing the active WebSocket connection.
13+
/// </value>
14+
public IWebSocketConnection Connection { get; }
15+
16+
/// <summary>
17+
/// Gets the subprotocol used for the WebSocket communication.
18+
/// </summary>
19+
/// <value>
20+
/// A <see cref="string"/> specifying the subprotocol negotiated for the WebSocket connection.
21+
/// </value>
22+
public string SubProtocol { get; }
23+
24+
/// <summary>
25+
/// Gets the operation message containing details of the authentication operation.
26+
/// </summary>
27+
/// <value>
28+
/// An instance of <see cref="OperationMessage"/> that encapsulates the specifics of the authentication request.
29+
/// </value>
30+
public OperationMessage OperationMessage { get; }
31+
32+
/// <summary>
33+
/// Gets a list of the authentication schemes the authentication requirements are evaluated against.
34+
/// When no schemes are specified, the default authentication scheme is used.
35+
/// </summary>
36+
public IEnumerable<string> AuthenticationSchemes { get; }
37+
38+
/// <summary>
39+
/// Initializes a new instance of the <see cref="AuthenticationRequest"/> class.
40+
/// </summary>
41+
public AuthenticationRequest(IWebSocketConnection connection, string subProtocol, OperationMessage operationMessage, IEnumerable<string> authenticationSchemes)
42+
{
43+
Connection = connection;
44+
SubProtocol = subProtocol;
45+
OperationMessage = operationMessage;
46+
AuthenticationSchemes = authenticationSchemes;
47+
}
48+
}

0 commit comments

Comments
 (0)