Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JWT Bearer authentication package #82

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions GraphQL.AspNetCore3.sln
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CorsSample", "src\Samples\C
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Net48Sample", "src\Samples\Net48Sample\Net48Sample.csproj", "{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL.AspNetCore3.JwtBearer", "src\GraphQL.AspNetCore3.JwtBearer\GraphQL.AspNetCore3.JwtBearer.csproj", "{7FDCD730-A321-4147-998F-0F26549B0A39}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -105,6 +107,10 @@ Global
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C325FFAC-F5D6-411A-B93F-2B04AC8356D4}.Release|Any CPU.Build.0 = Release|Any CPU
{7FDCD730-A321-4147-998F-0F26549B0A39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7FDCD730-A321-4147-998F-0F26549B0A39}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7FDCD730-A321-4147-998F-0F26549B0A39}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7FDCD730-A321-4147-998F-0F26549B0A39}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,38 @@ Note that `InvokeAsync` will execute even if the protocol is disabled in the opt
disabling `HandleGet` or similar; `HandleAuthorizeAsync` and `HandleAuthorizeWebSocketConnectionAsync`
will not.

JWT Bearer authentication is provided by the `GraphQL.AspNetCore3.JwtBearer` package.
Like the above sample, it will look for an "Authorization" entry that starts with "Bearer "
and validate the token using the configured ASP.Net Core JWT Bearer authentication handler.
Configure it using the `AddJwtBearerAuthentication` extension method as shown
in the example below:

```csharp
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();

builder.Services.AddGraphQL(b => b
.AddAutoSchema<Query>()
.AddSystemTextJson()
.AddAuthorizationRule()
.AddJwtBearerAuthentication()
);

app.UseGraphQL("/graphql", config =>
{
// require that the user be authenticated
config.AuthorizationRequired = true;
});
```

Please note:

- If JWT Bearer is not the default authentication scheme, you will need to specify
the authentication scheme to use for GraphQL requests. See 'Authentication schemes'
below for more information.

- Events configured through `JwtBearerEvents` are not currently supported.

#### Authentication schemes

By default the role and policy requirements are validated against the current user as defined by
Expand Down
16 changes: 16 additions & 0 deletions migration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Version history / migration notes

## 7.0.0

GraphQL.AspNetCore3 v7 requires GraphQL.NET v8 or newer.

### New features

- Supports JWT WebSocket Authentication using the separately-provided `GraphQL.AspNetCore3.JwtBearer` package.
- Inherits most options configured by the `Microsoft.AspNetCore.Authentication.JwtBearer` package.
- Supports multiple authentication schemes, configurable via the `GraphQLHttpMiddlewareOptions.AuthenticationSchemes` property.
- Defaults to attempting the `AuthenticationOptions.DefaultAuthenticateScheme` scheme if not specified.

### Breaking changes

- `AuthenticationSchemes` property added to `IAuthorizationOptions` interface.
- `IWebSocketAuthenticationService.AuthenticateAsync` parameters refactored into an `AuthenticationRequest` class.

## 6.0.0

GraphQL.AspNetCore3 v6 requires GraphQL.NET v8 or newer.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using GraphQL.AspNetCore3;
using GraphQL.AspNetCore3.JwtBearer;
using GraphQL.DI;

namespace GraphQL;

/// <summary>
/// Extension methods for adding JWT bearer authentication to a GraphQL server for WebSocket communications.
/// </summary>
public static class AspNetCore3JwtBearerExtensions
{
/// <summary>
/// Adds JWT bearer authentication to a GraphQL server for WebSocket communications.
/// </summary>
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder)
{
builder.AddWebSocketAuthentication<JwtWebSocketAuthenticationService>();
return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0;netcoreapp2.1;netcoreapp3.1;net6.0;net8.0</TargetFrameworks>
<Description>JWT Bearer authentication for GraphQL projects</Description>
<IsPackable>true</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.*" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.*" Condition="'$(TargetFramework)' == 'net6.0'" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.*" Condition="'$(TargetFramework)' == 'netcoreapp3.1'" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.*" Condition="'$(TargetFramework)' == 'netcoreapp2.1'" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.*" Condition="'$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\GraphQL.AspNetCore3\GraphQL.AspNetCore3.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="System.Text" />
<Using Include="System.Threading" />
<Using Include="System.Threading.Tasks" />
</ItemGroup>

</Project>
153 changes: 153 additions & 0 deletions src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Parts of this code file are based on the JwtBearerHandler class in the Microsoft.AspNetCore.Authentication.JwtBearer package found at:
// https://github.com/dotnet/aspnetcore/blob/5493b413d1df3aaf00651bdf1cbd8135fa63f517/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs
//
// Those sections of code may be subject to the MIT license found at:
// https://github.com/dotnet/aspnetcore/blob/5493b413d1df3aaf00651bdf1cbd8135fa63f517/LICENSE.txt

using System.Security.Claims;
using GraphQL.AspNetCore3.WebSockets;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace GraphQL.AspNetCore3.JwtBearer;

/// <summary>
/// Authenticates WebSocket connections via the 'payload' of the initialization packet.
/// This is necessary because WebSocket connections initiated from the browser cannot
/// authenticate via HTTP headers.
/// <br/><br/>
/// Notes:
/// <list type="bullet">
/// <item>This class is not used when authenticating over GET/POST.</item>
/// <item>
/// This class pulls the <see cref="JwtBearerOptions"/> instance registered by ASP.NET Core during the call to
/// <see cref="JwtBearerExtensions.AddJwtBearer(AuthenticationBuilder, Action{JwtBearerOptions})">AddJwtBearer</see>
/// for the default or configured authentication scheme and authenticates the token
/// based on simplified logic used by <see cref="JwtBearerHandler"/>.
/// </item>
/// <item>
/// The expected format of the payload is <c>{"Authorization":"Bearer TOKEN"}</c> where TOKEN is the JSON Web Token (JWT),
/// mirroring the format of the 'Authorization' HTTP header.
/// </item>
/// <item>
/// Events configured in <see cref="JwtBearerOptions.Events"/> are not raised by this implementation.
/// </item>
/// </list>
/// </summary>
public class JwtWebSocketAuthenticationService : IWebSocketAuthenticationService
{
private readonly IGraphQLSerializer _graphQLSerializer;
private readonly IOptionsMonitor<JwtBearerOptions> _jwtBearerOptionsMonitor;
private readonly string[] _defaultAuthenticationSchemes;

/// <summary>
/// Initializes a new instance of the <see cref="JwtWebSocketAuthenticationService"/> class.
/// </summary>
public JwtWebSocketAuthenticationService(IGraphQLSerializer graphQLSerializer, IOptionsMonitor<JwtBearerOptions> jwtBearerOptionsMonitor, IOptions<AuthenticationOptions> authenticationOptions)
{
_graphQLSerializer = graphQLSerializer;
_jwtBearerOptionsMonitor = jwtBearerOptionsMonitor;
var defaultAuthenticationScheme = authenticationOptions.Value.DefaultAuthenticateScheme;
_defaultAuthenticationSchemes = defaultAuthenticationScheme != null ? [defaultAuthenticationScheme] : [];
Shane32 marked this conversation as resolved.
Show resolved Hide resolved
}

/// <inheritdoc/>
public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
{
var connection = authenticationRequest.Connection;
var operationMessage = authenticationRequest.OperationMessage;
var schemes = authenticationRequest.AuthenticationSchemes.Any() ? authenticationRequest.AuthenticationSchemes : _defaultAuthenticationSchemes;
try {
// for connections authenticated via HTTP headers, no need to reauthenticate
if (connection.HttpContext.User.Identity?.IsAuthenticated ?? false)
return;

// attempt to read the 'Authorization' key from the payload object and verify it contains "Bearer XXXXXXXX"
var authPayload = _graphQLSerializer.ReadNode<AuthPayload>(operationMessage.Payload);
if (authPayload != null && authPayload.Authorization != null && authPayload.Authorization.StartsWith("Bearer ", StringComparison.Ordinal)) {
// pull the token from the value
var token = authPayload.Authorization.Substring(7);

// try to authenticate with each of the configured authentication schemes
foreach (var scheme in schemes) {
var options = _jwtBearerOptionsMonitor.Get(scheme);

// follow logic simplified from JwtBearerHandler.HandleAuthenticateAsync, as follows:
var tokenValidationParameters = await SetupTokenValidationParametersAsync(options, connection.HttpContext).ConfigureAwait(false);
#if NET8_0_OR_GREATER
if (!options.UseSecurityTokenValidators) {
foreach (var tokenHandler in options.TokenHandlers) {
try {
var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tokenValidationParameters).ConfigureAwait(false);
if (tokenValidationResult.IsValid) {
var principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity);
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
connection.HttpContext.User = principal;
return;
}
} catch {
// no errors during authentication should throw an exception
// specifically, attempting to validate an invalid JWT token may result in an exception
}
}
} else {
#else
{
#endif
#pragma warning disable CS0618 // Type or member is obsolete
foreach (var validator in options.SecurityTokenValidators) {
if (validator.CanReadToken(token)) {
try {
var principal = validator.ValidateToken(token, tokenValidationParameters, out _);
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
connection.HttpContext.User = principal;
return;
} catch {
// no errors during authentication should throw an exception
// specifically, attempting to validate an invalid JWT token will result in an exception
}
}
}
#pragma warning restore CS0618 // Type or member is obsolete
}
}
}
} catch {
// no errors during authentication should throw an exception
// specifically, parsing invalid JSON will result in an exception
}
}

private static async ValueTask<TokenValidationParameters> SetupTokenValidationParametersAsync(JwtBearerOptions options, HttpContext httpContext)
{
// Clone to avoid cross request race conditions for updated configurations.
var tokenValidationParameters = options.TokenValidationParameters.Clone();

#if NET8_0_OR_GREATER
if (options.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) {
tokenValidationParameters.ConfigurationManager = baseConfigurationManager;
} else {
#else
{
#endif
if (options.ConfigurationManager != null) {
// GetConfigurationAsync has a time interval that must pass before new http request will be issued.
var configuration = await options.ConfigurationManager.GetConfigurationAsync(httpContext.RequestAborted).ConfigureAwait(false);
var issuers = new[] { configuration.Issuer };
tokenValidationParameters.ValidIssuers = (tokenValidationParameters.ValidIssuers == null ? issuers : tokenValidationParameters.ValidIssuers.Concat(issuers));
tokenValidationParameters.IssuerSigningKeys = (tokenValidationParameters.IssuerSigningKeys == null ? configuration.SigningKeys : tokenValidationParameters.IssuerSigningKeys.Concat(configuration.SigningKeys));
}
}

return tokenValidationParameters;
}

private sealed class AuthPayload
{
public string? Authorization { get; set; }
}
}
2 changes: 2 additions & 0 deletions src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions
/// </summary>
public List<string> AuthenticationSchemes { get; set; } = new();

IEnumerable<string> IAuthorizationOptions.AuthenticationSchemes => AuthenticationSchemes;

/// <inheritdoc/>
/// <remarks>
/// HTTP requests return <c>401 Forbidden</c> when the request is not authenticated.
Expand Down
6 changes: 6 additions & 0 deletions src/GraphQL.AspNetCore3/IAuthorizationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ namespace GraphQL.AspNetCore3;
/// </summary>
public interface IAuthorizationOptions
{
/// <summary>
/// Gets a list of the authentication schemes the authentication requirements are evaluated against.
/// When no schemes are specified, the default authentication scheme is used.
/// </summary>
IEnumerable<string> AuthenticationSchemes { get; }

/// <summary>
/// If set, requires that <see cref="IIdentity.IsAuthenticated"/> return <see langword="true"/>
/// for the user within <see cref="HttpContext.User"/>
Expand Down
48 changes: 48 additions & 0 deletions src/GraphQL.AspNetCore3/WebSockets/AuthenticationRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace GraphQL.AspNetCore3.WebSockets;

/// <summary>
/// Represents an authentication request within the GraphQL ASP.NET Core WebSocket context.
/// </summary>
public class AuthenticationRequest
{
/// <summary>
/// Gets the WebSocket connection associated with the authentication request.
/// </summary>
/// <value>
/// An instance of <see cref="IWebSocketConnection"/> representing the active WebSocket connection.
/// </value>
public IWebSocketConnection Connection { get; }

/// <summary>
/// Gets the subprotocol used for the WebSocket communication.
/// </summary>
/// <value>
/// A <see cref="string"/> specifying the subprotocol negotiated for the WebSocket connection.
/// </value>
public string SubProtocol { get; }

/// <summary>
/// Gets the operation message containing details of the authentication operation.
/// </summary>
/// <value>
/// An instance of <see cref="OperationMessage"/> that encapsulates the specifics of the authentication request.
/// </value>
public OperationMessage OperationMessage { get; }

/// <summary>
/// Gets 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 IEnumerable<string> AuthenticationSchemes { get; }

/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationRequest"/> class.
/// </summary>
public AuthenticationRequest(IWebSocketConnection connection, string subProtocol, OperationMessage operationMessage, IEnumerable<string> authenticationSchemes)
{
Connection = connection;
SubProtocol = subProtocol;
OperationMessage = operationMessage;
AuthenticationSchemes = authenticationSchemes;
}
Shane32 marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ protected virtual Task ErrorIdAlreadyExistsAsync(OperationMessage message)
/// <see cref="OnNotAuthorizedRoleAsync(OperationMessage)">OnNotAuthorizedRoleAsync</see>
/// or <see cref="OnNotAuthorizedPolicyAsync(OperationMessage, AuthorizationResult)">OnNotAuthorizedPolicyAsync</see>.
/// <br/><br/>
/// Derived implementations should call the <see cref="IWebSocketAuthenticationService.AuthenticateAsync(IWebSocketConnection, string, OperationMessage)"/>
/// Derived implementations should call the <see cref="IWebSocketAuthenticationService.AuthenticateAsync(AuthenticationRequest)"/>
/// method to authenticate the request, and then call this base method.
/// <br/><br/>
/// This method will return <see langword="true"/> if authorization is successful, or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace GraphQL.AspNetCore3.WebSockets.GraphQLWs;
public class SubscriptionServer : BaseSubscriptionServer
{
private readonly IWebSocketAuthenticationService? _authenticationService;
private readonly IEnumerable<string> _authenticationSchemes;
private readonly IGraphQLSerializer _serializer;
private readonly GraphQLWebSocketOptions _options;
private DateTime _lastPongReceivedUtc;
Expand Down Expand Up @@ -76,6 +77,7 @@ public SubscriptionServer(
_authenticationService = authenticationService;
_serializer = serializer;
_options = options;
_authenticationSchemes = authorizationOptions.AuthenticationSchemes;
}

/// <inheritdoc/>
Expand Down Expand Up @@ -305,7 +307,7 @@ protected override async Task<ExecutionResult> ExecuteRequestAsync(OperationMess
/// <summary>
/// Authorizes an incoming GraphQL over WebSockets request with the connection initialization message and initializes the <see cref="UserContext"/>.
/// <br/><br/>
/// The default implementation calls the <see cref="IWebSocketAuthenticationService.AuthenticateAsync(IWebSocketConnection, string, OperationMessage)"/>
/// The default implementation calls the <see cref="IWebSocketAuthenticationService.AuthenticateAsync(AuthenticationRequest)"/>
/// method to authenticate the request (if <see cref="IWebSocketAuthenticationService"/> was specified),
/// checks the authorization rules set in <see cref="GraphQLHttpMiddlewareOptions"/>,
/// if any, against <see cref="HttpContext.User"/>. If validation fails, control is passed
Expand All @@ -323,7 +325,7 @@ protected override async Task<ExecutionResult> ExecuteRequestAsync(OperationMess
protected override async ValueTask<bool> AuthorizeAsync(OperationMessage message)
{
if (_authenticationService != null)
await _authenticationService.AuthenticateAsync(Connection, SubProtocol, message);
await _authenticationService.AuthenticateAsync(new(Connection, SubProtocol, message, _authenticationSchemes));
Shane32 marked this conversation as resolved.
Show resolved Hide resolved

bool success = await base.AuthorizeAsync(message);

Expand Down
Loading
Loading