Skip to content
Merged
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
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;
}

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
2 changes: 1 addition & 1 deletion src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<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>net6.0;net8.0</TargetFrameworks>
</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>
Expand Down