Skip to content

Commit

Permalink
Merge branch 'master' into dispose_once
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 authored Dec 1, 2024
2 parents 8d388b8 + cba251c commit eeeb44b
Show file tree
Hide file tree
Showing 20 changed files with 756 additions and 16 deletions.
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.DefaultScheme` 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>
158 changes: 158 additions & 0 deletions src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// 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>
/// <item>
/// Implementation does not call <see cref="Microsoft.Extensions.Logging.ILogger"/> to log authentication events.
/// </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.DefaultScheme;
_defaultAuthenticationSchemes = defaultAuthenticationScheme != null ? [defaultAuthenticationScheme] : [];
}

/// <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;
}

#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public sealed class AuthPayload
{
public string? Authorization { get; set; }
}
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
}
1 change: 0 additions & 1 deletion src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using static System.Net.Mime.MediaTypeNames;

namespace GraphQL.AspNetCore3;

Expand Down
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;
}
}
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
Loading

0 comments on commit eeeb44b

Please sign in to comment.