Skip to content

Commit 5184bfd

Browse files
authored
Add capability to raise JwtBearer events (#86)
* Add capability to enable JwtBearer events * fix comment * update formatting * update * delete net core 3.1 / 5.0 testing * update * update * update
1 parent 2ea3660 commit 5184bfd

9 files changed

Lines changed: 225 additions & 7 deletions

src/GraphQL.AspNetCore3.JwtBearer/AspNetCore3JwtBearerExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ public static class AspNetCore3JwtBearerExtensions
1313
/// Adds JWT bearer authentication to a GraphQL server for WebSocket communications.
1414
/// </summary>
1515
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder)
16+
=> builder.AddJwtBearerAuthentication(options => { });
17+
18+
/// <inheritdoc cref="AddJwtBearerAuthentication(IGraphQLBuilder)"/>
19+
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, bool enableJwtEvents)
20+
=> builder.AddJwtBearerAuthentication(options => options.EnableJwtEvents = enableJwtEvents);
21+
22+
/// <inheritdoc cref="AddJwtBearerAuthentication(IGraphQLBuilder)"/>
23+
public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, Action<JwtBearerAuthenticationOptions> configureOptions)
1624
{
25+
builder.Services.Configure(configureOptions);
1726
builder.AddWebSocketAuthentication<JwtWebSocketAuthenticationService>();
1827
return builder;
1928
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Microsoft.AspNetCore.Authentication.JwtBearer;
2+
3+
namespace GraphQL.AspNetCore3.JwtBearer;
4+
5+
/// <summary>
6+
/// Options for JWT Bearer authentication in GraphQL WebSocket connections.
7+
/// </summary>
8+
public class JwtBearerAuthenticationOptions
9+
{
10+
/// <summary>
11+
/// Gets or sets a value indicating whether JWT events should be enabled.
12+
/// When enabled, the <see cref="JwtWebSocketAuthenticationService"/> will raise the
13+
/// <see cref="JwtBearerEvents.MessageReceived"/>, <see cref="JwtBearerEvents.TokenValidated"/>,
14+
/// and <see cref="JwtBearerEvents.AuthenticationFailed"/> events as appropriate.
15+
/// </summary>
16+
public bool EnableJwtEvents { get; set; }
17+
}

src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ namespace GraphQL.AspNetCore3.JwtBearer;
3434
/// mirroring the format of the 'Authorization' HTTP header.
3535
/// </item>
3636
/// <item>
37-
/// Events configured in <see cref="JwtBearerOptions.Events"/> are not raised by this implementation.
37+
/// When JWT events are enabled via <see cref="JwtBearerAuthenticationOptions.EnableJwtEvents"/>, this implementation
38+
/// will raise the <see cref="JwtBearerEvents.MessageReceived"/>, <see cref="JwtBearerEvents.TokenValidated"/>,
39+
/// and <see cref="JwtBearerEvents.AuthenticationFailed"/> events as appropriate.
3840
/// </item>
3941
/// <item>
4042
/// Implementation does not call <see cref="Microsoft.Extensions.Logging.ILogger"/> to log authentication events.
@@ -46,16 +48,25 @@ public class JwtWebSocketAuthenticationService : IWebSocketAuthenticationService
4648
private readonly IGraphQLSerializer _graphQLSerializer;
4749
private readonly IOptionsMonitor<JwtBearerOptions> _jwtBearerOptionsMonitor;
4850
private readonly string[] _defaultAuthenticationSchemes;
51+
private readonly JwtBearerAuthenticationOptions _jwtBearerAuthenticationOptions;
52+
private readonly IAuthenticationSchemeProvider _schemeProvider;
4953

5054
/// <summary>
5155
/// Initializes a new instance of the <see cref="JwtWebSocketAuthenticationService"/> class.
5256
/// </summary>
53-
public JwtWebSocketAuthenticationService(IGraphQLSerializer graphQLSerializer, IOptionsMonitor<JwtBearerOptions> jwtBearerOptionsMonitor, IOptions<AuthenticationOptions> authenticationOptions)
57+
public JwtWebSocketAuthenticationService(
58+
IGraphQLSerializer graphQLSerializer,
59+
IOptionsMonitor<JwtBearerOptions> jwtBearerOptionsMonitor,
60+
IOptions<AuthenticationOptions> authenticationOptions,
61+
IOptions<JwtBearerAuthenticationOptions> jwtBearerAuthenticationOptions,
62+
IAuthenticationSchemeProvider schemeProvider)
5463
{
5564
_graphQLSerializer = graphQLSerializer;
5665
_jwtBearerOptionsMonitor = jwtBearerOptionsMonitor;
5766
var defaultAuthenticationScheme = authenticationOptions.Value.DefaultScheme;
5867
_defaultAuthenticationSchemes = defaultAuthenticationScheme != null ? [defaultAuthenticationScheme] : [];
68+
_jwtBearerAuthenticationOptions = jwtBearerAuthenticationOptions.Value;
69+
_schemeProvider = schemeProvider;
5970
}
6071

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

93+
// If JWT events are enabled, trigger the MessageReceived event
94+
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
95+
var messageResult = await TriggerMessageReceivedEventAsync(connection.HttpContext, options, token, scheme).ConfigureAwait(false);
96+
if (messageResult.Handled) {
97+
if (messageResult.Success) {
98+
connection.HttpContext.User = messageResult.Principal!;
99+
return;
100+
}
101+
continue;
102+
}
103+
104+
token = messageResult.Token;
105+
}
106+
82107
// follow logic simplified from JwtBearerHandler.HandleAuthenticateAsync, as follows:
83108
var tokenValidationParameters = await SetupTokenValidationParametersAsync(options, connection.HttpContext).ConfigureAwait(false);
84109
#if NET8_0_OR_GREATER
@@ -88,11 +113,35 @@ public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
88113
var tokenValidationResult = await tokenHandler.ValidateTokenAsync(token, tokenValidationParameters).ConfigureAwait(false);
89114
if (tokenValidationResult.IsValid) {
90115
var principal = new ClaimsPrincipal(tokenValidationResult.ClaimsIdentity);
116+
117+
// If JWT events are enabled, trigger the TokenValidated event
118+
if (_jwtBearerAuthenticationOptions.EnableJwtEvents)
119+
{
120+
var validatedResult = await TriggerTokenValidatedEventAsync(connection.HttpContext, options, principal, tokenValidationResult.SecurityToken, scheme).ConfigureAwait(false);
121+
if (validatedResult.Handled && !validatedResult.Success)
122+
{
123+
continue;
124+
}
125+
126+
principal = validatedResult.Principal ?? principal;
127+
}
128+
91129
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
92130
connection.HttpContext.User = principal;
93131
return;
94132
}
95-
} catch {
133+
} catch (Exception ex) {
134+
// If JWT events are enabled, trigger the AuthenticationFailed event
135+
if (_jwtBearerAuthenticationOptions.EnableJwtEvents)
136+
{
137+
var failedResult = await TriggerAuthenticationFailedEventAsync(connection.HttpContext, options, ex, scheme).ConfigureAwait(false);
138+
if (failedResult.Handled && failedResult.Success)
139+
{
140+
connection.HttpContext.User = failedResult.Principal!;
141+
return;
142+
}
143+
}
144+
96145
// no errors during authentication should throw an exception
97146
// specifically, attempting to validate an invalid JWT token may result in an exception
98147
}
@@ -105,11 +154,31 @@ public async Task AuthenticateAsync(AuthenticationRequest authenticationRequest)
105154
foreach (var validator in options.SecurityTokenValidators) {
106155
if (validator.CanReadToken(token)) {
107156
try {
108-
var principal = validator.ValidateToken(token, tokenValidationParameters, out _);
157+
var principal = validator.ValidateToken(token, tokenValidationParameters, out var securityToken);
158+
159+
// If JWT events are enabled, trigger the TokenValidated event
160+
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
161+
var validatedResult = await TriggerTokenValidatedEventAsync(connection.HttpContext, options, principal, securityToken, scheme).ConfigureAwait(false);
162+
if (validatedResult.Handled && !validatedResult.Success) {
163+
continue;
164+
}
165+
166+
principal = validatedResult.Principal ?? principal;
167+
}
168+
109169
// set the ClaimsPrincipal for the HttpContext; authentication will take place against this object
110170
connection.HttpContext.User = principal;
111171
return;
112-
} catch {
172+
} catch (Exception ex) {
173+
// If JWT events are enabled, trigger the AuthenticationFailed event
174+
if (_jwtBearerAuthenticationOptions.EnableJwtEvents) {
175+
var failedResult = await TriggerAuthenticationFailedEventAsync(connection.HttpContext, options, ex, scheme).ConfigureAwait(false);
176+
if (failedResult.Handled && failedResult.Success) {
177+
connection.HttpContext.User = failedResult.Principal!;
178+
return;
179+
}
180+
}
181+
113182
// no errors during authentication should throw an exception
114183
// specifically, attempting to validate an invalid JWT token will result in an exception
115184
}
@@ -149,6 +218,94 @@ private static async ValueTask<TokenValidationParameters> SetupTokenValidationPa
149218
return tokenValidationParameters;
150219
}
151220

221+
private async Task<EventResult> TriggerMessageReceivedEventAsync(HttpContext httpContext, JwtBearerOptions options, string token, string schemeName)
222+
{
223+
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
224+
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");
225+
226+
var messageReceivedContext = new MessageReceivedContext(httpContext, scheme, options) {
227+
Token = token
228+
};
229+
230+
if (options.Events != null && options.Events.MessageReceived != null) {
231+
await options.Events.MessageReceived(messageReceivedContext).ConfigureAwait(false);
232+
}
233+
234+
var result = new EventResult { Token = messageReceivedContext.Token };
235+
236+
// If the event provided a principal, use it directly
237+
if (messageReceivedContext.Result?.Succeeded == true) {
238+
result.Handled = true;
239+
result.Success = true;
240+
result.Principal = messageReceivedContext.Principal;
241+
}
242+
243+
return result;
244+
}
245+
246+
private async Task<EventResult> TriggerTokenValidatedEventAsync(HttpContext httpContext, JwtBearerOptions options, ClaimsPrincipal principal, SecurityToken securityToken, string schemeName)
247+
{
248+
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
249+
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");
250+
251+
var tokenValidatedContext = new TokenValidatedContext(httpContext, scheme, options) {
252+
Principal = principal,
253+
SecurityToken = securityToken
254+
};
255+
256+
if (options.Events != null && options.Events.TokenValidated != null) {
257+
await options.Events.TokenValidated(tokenValidatedContext).ConfigureAwait(false);
258+
}
259+
260+
var result = new EventResult();
261+
262+
// If the event failed or replaced the principal
263+
if (tokenValidatedContext.Result != null) {
264+
result.Handled = true;
265+
result.Success = tokenValidatedContext.Result.Succeeded;
266+
if (tokenValidatedContext.Result.Succeeded) {
267+
result.Principal = tokenValidatedContext.Principal;
268+
}
269+
}
270+
271+
return result;
272+
}
273+
274+
private async Task<EventResult> TriggerAuthenticationFailedEventAsync(HttpContext httpContext, JwtBearerOptions options, Exception exception, string schemeName)
275+
{
276+
var scheme = await _schemeProvider.GetSchemeAsync(schemeName)
277+
?? throw new InvalidOperationException($"Authentication scheme '{schemeName}' not found.");
278+
279+
var authenticationFailedContext = new AuthenticationFailedContext(httpContext, scheme, options) {
280+
Exception = exception
281+
};
282+
283+
if (options.Events != null && options.Events.AuthenticationFailed != null) {
284+
await options.Events.AuthenticationFailed(authenticationFailedContext).ConfigureAwait(false);
285+
}
286+
287+
var result = new EventResult();
288+
289+
// If the event handled the exception and succeeded
290+
if (authenticationFailedContext.Result != null) {
291+
result.Handled = true;
292+
result.Success = authenticationFailedContext.Result.Succeeded;
293+
if (authenticationFailedContext.Result.Succeeded) {
294+
result.Principal = authenticationFailedContext.Principal;
295+
}
296+
}
297+
298+
return result;
299+
}
300+
301+
private sealed class EventResult
302+
{
303+
public bool Handled { get; set; }
304+
public bool Success { get; set; }
305+
public ClaimsPrincipal? Principal { get; set; }
306+
public string Token { get; set; } = string.Empty;
307+
}
308+
152309
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
153310
public sealed class AuthPayload
154311
{

src/Tests.ApiApprovals/ApiApprovalTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using GraphQL.AspNetCore3;
2+
using GraphQL.AspNetCore3.JwtBearer;
23
using PublicApiGenerator;
34
using Shouldly;
45
using Xunit;
@@ -12,6 +13,7 @@ public class ApiApprovalTests
1213
{
1314
[Theory]
1415
[InlineData(typeof(GraphQLHttpMiddleware))]
16+
[InlineData(typeof(JwtWebSocketAuthenticationService))]
1517
public void PublicApi(Type type)
1618
{
1719
string publicApi = type.Assembly.GeneratePublicApi(new ApiGeneratorOptions {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace GraphQL.AspNetCore3.JwtBearer
2+
{
3+
public class JwtBearerAuthenticationOptions
4+
{
5+
public JwtBearerAuthenticationOptions() { }
6+
public bool EnableJwtEvents { get; set; }
7+
}
8+
public class JwtWebSocketAuthenticationService : GraphQL.AspNetCore3.WebSockets.IWebSocketAuthenticationService
9+
{
10+
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) { }
11+
public System.Threading.Tasks.Task AuthenticateAsync(GraphQL.AspNetCore3.WebSockets.AuthenticationRequest authenticationRequest) { }
12+
public sealed class AuthPayload
13+
{
14+
public AuthPayload() { }
15+
public string? Authorization { get; set; }
16+
}
17+
}
18+
}
19+
namespace GraphQL
20+
{
21+
public static class AspNetCore3JwtBearerExtensions
22+
{
23+
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder) { }
24+
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder, System.Action<GraphQL.AspNetCore3.JwtBearer.JwtBearerAuthenticationOptions> configureOptions) { }
25+
public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder, bool enableJwtEvents) { }
26+
}
27+
}

src/Tests.ApiApprovals/Tests.ApiTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
</ItemGroup>
1717

1818
<ItemGroup>
19+
<ProjectReference Include="..\GraphQL.AspNetCore3.JwtBearer\GraphQL.AspNetCore3.JwtBearer.csproj" />
1920
<ProjectReference Include="..\GraphQL.AspNetCore3\GraphQL.AspNetCore3.csproj" />
2021
</ItemGroup>
2122

src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ public void AddJwtBearerAuthentication_ShouldAddJwtWebSocketAuthenticationServic
2626
false))
2727
.Returns(serviceRegisterMock.Object);
2828

29+
// Setup the Configure method to accept any Action<JwtBearerAuthenticationOptions, IServiceProvider>
30+
serviceRegisterMock
31+
.Setup(x => x.Configure<JwtBearerAuthenticationOptions>(It.IsAny<Action<JwtBearerAuthenticationOptions, IServiceProvider>>()))
32+
.Returns(serviceRegisterMock.Object);
33+
2934
// Act
3035
var result = graphQLBuilderMock.Object.AddJwtBearerAuthentication();
3136

src/Tests/JwtBearer/JwtWebSocketAuthenticationServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ private TestServer CreateTestServer(bool defaultScheme = true, bool customScheme
253253
services.AddGraphQL(b => b
254254
.AddSchema(_schema)
255255
.AddSystemTextJson()
256-
.AddJwtBearerAuthentication()
256+
.AddJwtBearerAuthentication(true)
257257
);
258258
})
259259
.Configure(app => {

src/Tests/Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' != 'true'">
4-
<TargetFrameworks>netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0</TargetFrameworks>
4+
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
55
</PropertyGroup>
66
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' == 'true'">
77
<TargetFrameworks>net48;netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0</TargetFrameworks>

0 commit comments

Comments
 (0)