diff --git a/src/GraphQL.AspNetCore3.JwtBearer/AspNetCore3JwtBearerExtensions.cs b/src/GraphQL.AspNetCore3.JwtBearer/AspNetCore3JwtBearerExtensions.cs index aa73d7a..442e1ef 100644 --- a/src/GraphQL.AspNetCore3.JwtBearer/AspNetCore3JwtBearerExtensions.cs +++ b/src/GraphQL.AspNetCore3.JwtBearer/AspNetCore3JwtBearerExtensions.cs @@ -13,7 +13,16 @@ public static class AspNetCore3JwtBearerExtensions /// Adds JWT bearer authentication to a GraphQL server for WebSocket communications. /// public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder) + => builder.AddJwtBearerAuthentication(options => { }); + + /// + public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, bool enableJwtEvents) + => builder.AddJwtBearerAuthentication(options => options.EnableJwtEvents = enableJwtEvents); + + /// + public static IGraphQLBuilder AddJwtBearerAuthentication(this IGraphQLBuilder builder, Action configureOptions) { + builder.Services.Configure(configureOptions); builder.AddWebSocketAuthentication(); return builder; } diff --git a/src/GraphQL.AspNetCore3.JwtBearer/JwtBearerAuthenticationOptions.cs b/src/GraphQL.AspNetCore3.JwtBearer/JwtBearerAuthenticationOptions.cs new file mode 100644 index 0000000..05e953f --- /dev/null +++ b/src/GraphQL.AspNetCore3.JwtBearer/JwtBearerAuthenticationOptions.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace GraphQL.AspNetCore3.JwtBearer; + +/// +/// Options for JWT Bearer authentication in GraphQL WebSocket connections. +/// +public class JwtBearerAuthenticationOptions +{ + /// + /// Gets or sets a value indicating whether JWT events should be enabled. + /// When enabled, the will raise the + /// , , + /// and events as appropriate. + /// + public bool EnableJwtEvents { get; set; } +} diff --git a/src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs b/src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs index 6b30f71..89f5130 100644 --- a/src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs +++ b/src/GraphQL.AspNetCore3.JwtBearer/JwtWebSocketAuthenticationService.cs @@ -34,7 +34,9 @@ namespace GraphQL.AspNetCore3.JwtBearer; /// mirroring the format of the 'Authorization' HTTP header. /// /// -/// Events configured in are not raised by this implementation. +/// When JWT events are enabled via , this implementation +/// will raise the , , +/// and events as appropriate. /// /// /// Implementation does not call to log authentication events. @@ -46,16 +48,25 @@ public class JwtWebSocketAuthenticationService : IWebSocketAuthenticationService private readonly IGraphQLSerializer _graphQLSerializer; private readonly IOptionsMonitor _jwtBearerOptionsMonitor; private readonly string[] _defaultAuthenticationSchemes; + private readonly JwtBearerAuthenticationOptions _jwtBearerAuthenticationOptions; + private readonly IAuthenticationSchemeProvider _schemeProvider; /// /// Initializes a new instance of the class. /// - public JwtWebSocketAuthenticationService(IGraphQLSerializer graphQLSerializer, IOptionsMonitor jwtBearerOptionsMonitor, IOptions authenticationOptions) + public JwtWebSocketAuthenticationService( + IGraphQLSerializer graphQLSerializer, + IOptionsMonitor jwtBearerOptionsMonitor, + IOptions authenticationOptions, + IOptions jwtBearerAuthenticationOptions, + IAuthenticationSchemeProvider schemeProvider) { _graphQLSerializer = graphQLSerializer; _jwtBearerOptionsMonitor = jwtBearerOptionsMonitor; var defaultAuthenticationScheme = authenticationOptions.Value.DefaultScheme; _defaultAuthenticationSchemes = defaultAuthenticationScheme != null ? [defaultAuthenticationScheme] : []; + _jwtBearerAuthenticationOptions = jwtBearerAuthenticationOptions.Value; + _schemeProvider = schemeProvider; } /// @@ -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 @@ -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 } @@ -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 } @@ -149,6 +218,94 @@ private static async ValueTask SetupTokenValidationPa return tokenValidationParameters; } + private async Task 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 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 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 { diff --git a/src/Tests.ApiApprovals/ApiApprovalTests.cs b/src/Tests.ApiApprovals/ApiApprovalTests.cs index 9acb29b..2daf149 100644 --- a/src/Tests.ApiApprovals/ApiApprovalTests.cs +++ b/src/Tests.ApiApprovals/ApiApprovalTests.cs @@ -1,4 +1,5 @@ using GraphQL.AspNetCore3; +using GraphQL.AspNetCore3.JwtBearer; using PublicApiGenerator; using Shouldly; using Xunit; @@ -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 { diff --git a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.JwtBearer.approved.txt b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.JwtBearer.approved.txt new file mode 100644 index 0000000..96587a5 --- /dev/null +++ b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.JwtBearer.approved.txt @@ -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 jwtBearerOptionsMonitor, Microsoft.Extensions.Options.IOptions authenticationOptions, Microsoft.Extensions.Options.IOptions 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 configureOptions) { } + public static GraphQL.DI.IGraphQLBuilder AddJwtBearerAuthentication(this GraphQL.DI.IGraphQLBuilder builder, bool enableJwtEvents) { } + } +} diff --git a/src/Tests.ApiApprovals/Tests.ApiTests.csproj b/src/Tests.ApiApprovals/Tests.ApiTests.csproj index fcbb7d5..86c37e7 100644 --- a/src/Tests.ApiApprovals/Tests.ApiTests.csproj +++ b/src/Tests.ApiApprovals/Tests.ApiTests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs b/src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs index 2686cc0..0015b93 100644 --- a/src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs +++ b/src/Tests/JwtBearer/AspNetCore3JwtBearerExtensionsTests.cs @@ -26,6 +26,11 @@ public void AddJwtBearerAuthentication_ShouldAddJwtWebSocketAuthenticationServic false)) .Returns(serviceRegisterMock.Object); + // Setup the Configure method to accept any Action + serviceRegisterMock + .Setup(x => x.Configure(It.IsAny>())) + .Returns(serviceRegisterMock.Object); + // Act var result = graphQLBuilderMock.Object.AddJwtBearerAuthentication(); diff --git a/src/Tests/JwtBearer/JwtWebSocketAuthenticationServiceTests.cs b/src/Tests/JwtBearer/JwtWebSocketAuthenticationServiceTests.cs index f71f1be..670de68 100644 --- a/src/Tests/JwtBearer/JwtWebSocketAuthenticationServiceTests.cs +++ b/src/Tests/JwtBearer/JwtWebSocketAuthenticationServiceTests.cs @@ -253,7 +253,7 @@ private TestServer CreateTestServer(bool defaultScheme = true, bool customScheme services.AddGraphQL(b => b .AddSchema(_schema) .AddSystemTextJson() - .AddJwtBearerAuthentication() + .AddJwtBearerAuthentication(true) ); }) .Configure(app => { diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 6da495c..fbd5ab5 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -1,7 +1,7 @@ - netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0 + net6.0;net8.0 net48;netcoreapp2.1;netcoreapp3.1;net5.0;net6.0;net8.0