Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 0 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ jobs:
with:
dotnet-version: |
2.1.x
3.1.x
5.0.x
6.0.x
8.0.x
source-url: https://nuget.pkg.github.com/Shane32/index.json
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ public static class AspNetCore3JwtBearerExtensions
/// Adds JWT bearer authentication to a GraphQL server for WebSocket communications.
/// </summary>
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder)
=> builder.AddJwtBearerAuthentication(options => { });

Comment thread
Shane32 marked this conversation as resolved.
/// <inheritdoc cref="AddJwtBearerAuthentication(IGraphQLBuilder)"/>
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, bool enableJwtEvents)
=> builder.AddJwtBearerAuthentication(options => options.EnableJwtEvents = enableJwtEvents);

/// <inheritdoc cref="AddJwtBearerAuthentication(IGraphQLBuilder)"/>
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, Action<JwtBearerAuthenticationOptions> configureOptions)
{
builder.Services.Configure(configureOptions);
builder.AddWebSocketAuthentication<JwtWebSocketAuthenticationService>();
Comment thread
Shane32 marked this conversation as resolved.
return builder;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace GraphQL.AspNetCore3.JwtBearer;

/// <summary>
/// Options for JWT Bearer authentication in GraphQL WebSocket connections.
/// </summary>
public class JwtBearerAuthenticationOptions
{
/// <summary>
/// Gets or sets a value indicating whether JWT events should be enabled.
/// When enabled, the <see cref="JwtWebSocketAuthenticationService"/> will raise the
/// <see cref="JwtBearerEvents.MessageReceived"/>, <see cref="JwtBearerEvents.TokenValidated"/>,
/// and <see cref="JwtBearerEvents.AuthenticationFailed"/> events as appropriate.
/// </summary>
public bool EnableJwtEvents { get; set; }
}
167 changes: 162 additions & 5 deletions src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ namespace GraphQL.AspNetCore3.JwtBearer;
/// mirroring the format of the 'Authorization' HTTP header.
/// </item>
/// <item>
/// Events configured in <see cref="JwtBearerOptions.Events"/> are not raised by this implementation.
/// When JWT events are enabled via <see cref="JwtBearerAuthenticationOptions.EnableJwtEvents"/>, this implementation
/// will raise the <see cref="JwtBearerEvents.MessageReceived"/>, <see cref="JwtBearerEvents.TokenValidated"/>,
/// and <see cref="JwtBearerEvents.AuthenticationFailed"/> events as appropriate.
/// </item>
/// <item>
/// Implementation does not call <see cref="Microsoft.Extensions.Logging.ILogger"/> to log authentication events.
Expand All @@ -46,16 +48,25 @@ public class JwtWebSocketAuthenticationService : IWebSocketAuthenticationService
private readonly IGraphQLSerializer _graphQLSerializer;
private readonly IOptionsMonitor<JwtBearerOptions> _jwtBearerOptionsMonitor;
private readonly string[] _defaultAuthenticationSchemes;
private readonly JwtBearerAuthenticationOptions _jwtBearerAuthenticationOptions;
private readonly IAuthenticationSchemeProvider _schemeProvider;

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

/// <inheritdoc/>
Expand All @@ -79,6 +90,20 @@ public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
foreach (var scheme in schemes) {
var options = _jwtBearerOptionsMonitor.Get(scheme);

// If JWT events are enabled, trigger the MessageReceived event
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
var messageResult = await TriggerMessageReceivedEventAsync(connection.HttpContext, options, token, scheme).ConfigureAwait(false);
if (messageResult.Handled) {
if (messageResult.Success) {
connection.HttpContext.User = messageResult.Principal!;
return;
}
continue;
}

token = messageResult.Token;
}

// follow logic simplified from JwtBearerHandler.HandleAuthenticateAsync, as follows:
var tokenValidationParameters = await SetupTokenValidationParametersAsync(options, connection.HttpContext).ConfigureAwait(false);
#if NET8_0_OR_GREATER
Expand All @@ -88,11 +113,35 @@ public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tokenValidationParameters).ConfigureAwait(false);
if (tokenValidationResult.IsValid) {
var principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity);

// If JWT events are enabled, trigger the TokenValidated event
if (_jwtBearerAuthenticationOptions.EnableJwtEvents)
{
var validatedResult = await TriggerTokenValidatedEventAsync(connection.HttpContext, options, principal, tokenValidationResult.SecurityToken, scheme).ConfigureAwait(false);
if (validatedResult.Handled && !validatedResult.Success)
{
continue;
}

principal = validatedResult.Principal ?? principal;
}

// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
connection.HttpContext.User = principal;
return;
}
} catch {
} catch (Exception ex) {
// If JWT events are enabled, trigger the AuthenticationFailed event
if (_jwtBearerAuthenticationOptions.EnableJwtEvents)
{
var failedResult = await TriggerAuthenticationFailedEventAsync(connection.HttpContext, options, ex, scheme).ConfigureAwait(false);
if (failedResult.Handled && failedResult.Success)
{
connection.HttpContext.User = failedResult.Principal!;
return;
}
}

// no errors during authentication should throw an exception
// specifically, attempting to validate an invalid JWT token may result in an exception
}
Expand All @@ -105,11 +154,31 @@ public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
foreach (var validator in options.SecurityTokenValidators) {
if (validator.CanReadToken(token)) {
try {
var principal = validator.ValidateToken(token, tokenValidationParameters, out _);
var principal = validator.ValidateToken(token, tokenValidationParameters, out var securityToken);

// If JWT events are enabled, trigger the TokenValidated event
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
var validatedResult = await TriggerTokenValidatedEventAsync(connection.HttpContext, options, principal, securityToken, scheme).ConfigureAwait(false);
if (validatedResult.Handled && !validatedResult.Success) {
continue;
}

principal = validatedResult.Principal ?? principal;
}

// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
connection.HttpContext.User = principal;
return;
} catch {
} catch (Exception ex) {
// If JWT events are enabled, trigger the AuthenticationFailed event
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
var failedResult = await TriggerAuthenticationFailedEventAsync(connection.HttpContext, options, ex, scheme).ConfigureAwait(false);
if (failedResult.Handled && failedResult.Success) {
connection.HttpContext.User = failedResult.Principal!;
return;
}
}

// no errors during authentication should throw an exception
// specifically, attempting to validate an invalid JWT token will result in an exception
}
Expand Down Expand Up @@ -149,6 +218,94 @@ private static async ValueTask<TokenValidationParameters> SetupTokenValidationPa
return tokenValidationParameters;
}

private async Task<EventResult> TriggerMessageReceivedEventAsync(HttpContext httpContext, JwtBearerOptions options, string token, string schemeName)
{
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");

var messageReceivedContext = new MessageReceivedContext(httpContext, scheme, options) {
Token = token
};

if (options.Events != null && options.Events.MessageReceived != null) {
await options.Events.MessageReceived(messageReceivedContext).ConfigureAwait(false);
}

var result = new EventResult { Token = messageReceivedContext.Token };

// If the event provided a principal, use it directly
if (messageReceivedContext.Result?.Succeeded == true) {
result.Handled = true;
result.Success = true;
result.Principal = messageReceivedContext.Principal;
}

return result;
}
Comment thread
Shane32 marked this conversation as resolved.

private async Task<EventResult> TriggerTokenValidatedEventAsync(HttpContext httpContext, JwtBearerOptions options, ClaimsPrincipal principal, SecurityToken securityToken, string schemeName)
{
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");

var tokenValidatedContext = new TokenValidatedContext(httpContext, scheme, options) {
Principal = principal,
SecurityToken = securityToken
};

if (options.Events != null && options.Events.TokenValidated != null) {
await options.Events.TokenValidated(tokenValidatedContext).ConfigureAwait(false);
}

var result = new EventResult();

// If the event failed or replaced the principal
if (tokenValidatedContext.Result != null) {
result.Handled = true;
result.Success = tokenValidatedContext.Result.Succeeded;
if (tokenValidatedContext.Result.Succeeded) {
result.Principal = tokenValidatedContext.Principal;
}
}

return result;
}

private async Task<EventResult> TriggerAuthenticationFailedEventAsync(HttpContext httpContext, JwtBearerOptions options, Exception exception, string schemeName)
{
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");

var authenticationFailedContext = new AuthenticationFailedContext(httpContext, scheme, options) {
Exception = exception
};

if (options.Events != null && options.Events.AuthenticationFailed != null) {
await options.Events.AuthenticationFailed(authenticationFailedContext).ConfigureAwait(false);
}

var result = new EventResult();

// If the event handled the exception and succeeded
if (authenticationFailedContext.Result != null) {
result.Handled = true;
result.Success = authenticationFailedContext.Result.Succeeded;
if (authenticationFailedContext.Result.Succeeded) {
result.Principal = authenticationFailedContext.Principal;
}
}

return result;
}

private sealed class EventResult
{
public bool Handled { get; set; }
public bool Success { get; set; }
public ClaimsPrincipal? Principal { get; set; }
public string Token { get; set; } = string.Empty;
}

#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public sealed class AuthPayload
{
Expand Down
2 changes: 2 additions & 0 deletions src/Tests.ApiApprovals/ApiApprovalTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using GraphQL.AspNetCore3;
using GraphQL.AspNetCore3.JwtBearer;
using PublicApiGenerator;
using Shouldly;
using Xunit;
Expand All @@ -12,6 +13,7 @@ public class ApiApprovalTests
{
[Theory]
[InlineData(typeof(GraphQLHttpMiddleware))]
[InlineData(typeof(JwtWebSocketAuthenticationService))]
public void PublicApi(Type type)
{
string publicApi = type.Assembly.GeneratePublicApi(new ApiGeneratorOptions {
Expand Down
27 changes: 27 additions & 0 deletions src/Tests.ApiApprovals/GraphQL.AspNetCore3.JwtBearer.approved.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace GraphQL.AspNetCore3.JwtBearer
{
public class JwtBearerAuthenticationOptions
{
public JwtBearerAuthenticationOptions() { }
public bool EnableJwtEvents { get; set; }
}
public class JwtWebSocketAuthenticationService : GraphQL.AspNetCore3.WebSockets.IWebSocketAuthenticationService
{
public JwtWebSocketAuthenticationService(GraphQL.IGraphQLSerializer graphQLSerializer, Microsoft.Extensions.Options.IOptionsMonitor<Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions> jwtBearerOptionsMonitor, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Authentication.AuthenticationOptions> authenticationOptions, Microsoft.Extensions.Options.IOptions<GraphQL.AspNetCore3.JwtBearer.JwtBearerAuthenticationOptions> jwtBearerAuthenticationOptions, Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider schemeProvider) { }
public System.Threading.Tasks.Task AuthenticateAsync(GraphQL.AspNetCore3.WebSockets.AuthenticationRequest authenticationRequest) { }
public sealed class AuthPayload
{
public AuthPayload() { }
public string? Authorization { get; set; }
}
}
}
namespace GraphQL
{
public static class AspNetCore3JwtBearerExtensions
{
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder) { }
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder, System.Action<GraphQL.AspNetCore3.JwtBearer.JwtBearerAuthenticationOptions> configureOptions) { }
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder, bool enableJwtEvents) { }
}
}
1 change: 1 addition & 0 deletions src/Tests.ApiApprovals/Tests.ApiTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
</ItemGroup>

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

Expand Down
5 changes: 5 additions & 0 deletions src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public void AddJwtBearerAuthentication_ShouldAddJwtWebSocketAuthenticationServic
false))
.Returns(serviceRegisterMock.Object);

// Setup the Configure method to accept any Action<JwtBearerAuthenticationOptions, IServiceProvider>
serviceRegisterMock
.Setup(x => x.Configure<JwtBearerAuthenticationOptions>(It.IsAny<Action<JwtBearerAuthenticationOptions, IServiceProvider>>()))
.Returns(serviceRegisterMock.Object);

// Act
var result = graphQLBuilderMock.Object.AddJwtBearerAuthentication();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ private TestServer CreateTestServer(bool defaultScheme = true, bool customScheme
services.AddGraphQL(b => b
.AddSchema(_schema)
.AddSystemTextJson()
.AddJwtBearerAuthentication()
.AddJwtBearerAuthentication(true)
);
})
.Configure(app => {
Expand Down
20 changes: 2 additions & 18 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' != 'true'">
<TargetFrameworks>netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>netcoreapp2.1;net6.0;net8.0</TargetFrameworks>
Comment thread
Shane32 marked this conversation as resolved.
Outdated
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'">
<TargetFrameworks>net48;netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net48;netcoreapp2.1;net6.0;net8.0</TargetFrameworks>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -75,22 +75,6 @@
<Version>6.0.*</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net5.0'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing">
<Version>5.0.*</Version>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer">
<Version>5.0.*</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp3.1'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing">
<Version>3.1.*</Version>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer">
<Version>3.1.*</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netcoreapp2.1' OR '$(TargetFramework)' == 'net48'">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing">
<Version>2.1.*</Version>
Expand Down