diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f75fcaa..e2dfa5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +# JWT.Extensions.AspNetCore 11.0.0-beta3 + +- Converted to use the event model to allow dependency injection with custom event classes. + # JWT 11.0.0-beta2, JWT.Extensions.AspNetCore 11.0.0-beta2, JWT.Extensions.DependencyInjection 3.0.0-beta2 - Replaced .NET 7 with .NET 8 whenever applicable diff --git a/src/JWT.Extensions.AspNetCore/FailedTicketContext.cs b/src/JWT.Extensions.AspNetCore/FailedTicketContext.cs new file mode 100644 index 00000000..0c7952f2 --- /dev/null +++ b/src/JWT.Extensions.AspNetCore/FailedTicketContext.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace JWT.Extensions.AspNetCore +{ + public class FailedTicketContext + { + public FailedTicketContext(ILogger logger, Exception exception, HttpContext context, JwtAuthenticationOptions options) + { + this.Logger = logger; + this.Exception = exception; + this.Context = context; + this.Options = options; + } + + public ILogger Logger { get; } + + public Exception Exception { get; } + + public HttpContext Context { get; } + + public JwtAuthenticationOptions Options { get; } + } +} diff --git a/src/JWT.Extensions.AspNetCore/JWT.Extensions.AspNetCore.csproj b/src/JWT.Extensions.AspNetCore/JWT.Extensions.AspNetCore.csproj index 80287262..107bbfc1 100644 --- a/src/JWT.Extensions.AspNetCore/JWT.Extensions.AspNetCore.csproj +++ b/src/JWT.Extensions.AspNetCore/JWT.Extensions.AspNetCore.csproj @@ -10,7 +10,7 @@ Alexander Batishchev jwt;json;asp.net;asp.net core;.net core;authorization MIT - 11.0.0-beta2 + 11.0.0-beta3 11.0.0.0 11.0.0.0 JWT.Extensions.AspNetCore diff --git a/src/JWT.Extensions.AspNetCore/JwtAuthenticationEvents.cs b/src/JWT.Extensions.AspNetCore/JwtAuthenticationEvents.cs new file mode 100644 index 00000000..0bd72781 --- /dev/null +++ b/src/JWT.Extensions.AspNetCore/JwtAuthenticationEvents.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace JWT.Extensions.AspNetCore +{ + public class JwtAuthenticationEvents + { + private static readonly Action _logMissingHeader; + private static readonly Action _logIncorrectScheme; + private static readonly Action _logEmptyHeader; + private static readonly Action _logSuccessfulTicket; + private static readonly Action _logFailedTicket; + + static JwtAuthenticationEvents() + { + _logMissingHeader = LoggerMessage.Define( + LogLevel.Information, + 10, + $"Header {nameof(HeaderNames.Authorization)} is empty, returning none"); + + _logIncorrectScheme = LoggerMessage.Define( + LogLevel.Information, + 11, + $"Header {nameof(HeaderNames.Authorization)} scheme is {{ActualScheme}}, expected {{ExpectedScheme}}, returning none"); + + _logEmptyHeader = LoggerMessage.Define( + LogLevel.Information, + 12, + $"Token in header {nameof(HeaderNames.Authorization)} is empty, returning none"); + + _logSuccessfulTicket = LoggerMessage.Define( + LogLevel.Information, + 1, + "Successfully decoded JWT, returning success"); + + _logFailedTicket = LoggerMessage.Define( + LogLevel.Information, + 2, + "Error decoding JWT: {0}, returning failure"); + } + + public Func OnMissingHeader { get; set; } = + logger => + { + _logMissingHeader(logger, null); + return AuthenticateResult.NoResult(); + }; + + public Func OnIncorrectScheme { get; set; } = + (logger, actualScheme, expectedScheme) => + { + _logIncorrectScheme(logger, actualScheme, expectedScheme, null); + return AuthenticateResult.NoResult(); + }; + + public Func OnEmptyHeader { get; set; } = (logger, header) => + { + _logEmptyHeader(logger, null); + return AuthenticateResult.NoResult(); + }; + + public Func OnSuccessfulTicket { get; set; } = context => + { + _logSuccessfulTicket(context.Logger, null); + return AuthenticateResult.Success(context.Ticket); + }; + + public Func OnFailedTicket { get; set; } = context => + { + _logFailedTicket(context.Logger, context.Exception.Message, context.Exception); + return AuthenticateResult.Fail(context.Exception); + }; + + public virtual AuthenticateResult SuccessfulTicket(SuccessfulTicketContext context) => OnSuccessfulTicket(context); + + public virtual AuthenticateResult FailedTicket(FailedTicketContext context) => OnFailedTicket(context); + + public virtual AuthenticateResult EmptyHeader(ILogger logger, string header) => OnEmptyHeader(logger, header); + + public virtual AuthenticateResult IncorrectScheme(ILogger logger, string actualScheme, string expectedScheme) => OnIncorrectScheme(logger, actualScheme, expectedScheme); + + public virtual AuthenticateResult MissingHeader(ILogger logger) => OnMissingHeader(logger); + + } +} diff --git a/src/JWT.Extensions.AspNetCore/JwtAuthenticationHandler.cs b/src/JWT.Extensions.AspNetCore/JwtAuthenticationHandler.cs index 7adc64af..68ac1e0d 100644 --- a/src/JWT.Extensions.AspNetCore/JwtAuthenticationHandler.cs +++ b/src/JWT.Extensions.AspNetCore/JwtAuthenticationHandler.cs @@ -12,44 +12,10 @@ namespace JWT.Extensions.AspNetCore { public sealed class JwtAuthenticationHandler : AuthenticationHandler { - private static readonly Action _logMissingHeader; - private static readonly Action _logIncorrectScheme; - private static readonly Action _logEmptyHeader; - private static readonly Action _logSuccessfulTicket; - private static readonly Action _logFailedTicket; - private readonly IJwtDecoder _jwtDecoder; private readonly IIdentityFactory _identityFactory; private readonly ITicketFactory _ticketFactory; - static JwtAuthenticationHandler() - { - _logMissingHeader = LoggerMessage.Define( - LogLevel.Information, - 10, - $"Header {nameof(HeaderNames.Authorization)} is empty, returning none"); - - _logIncorrectScheme = LoggerMessage.Define( - LogLevel.Information, - 11, - $"Header {nameof(HeaderNames.Authorization)} scheme is {{0}}, expected {{1}}, returning none"); - - _logEmptyHeader = LoggerMessage.Define( - LogLevel.Information, - 12, - $"Token in header {nameof(HeaderNames.Authorization)} is empty, returning none"); - - _logSuccessfulTicket = LoggerMessage.Define( - LogLevel.Information, - 1, - "Successfully decoded JWT, returning success"); - - _logFailedTicket = LoggerMessage.Define( - LogLevel.Information, - 2, - "Error decoding JWT: {0}, returning failure"); - } - public JwtAuthenticationHandler( IJwtDecoder jwtDecoder, IIdentityFactory identityFactory, @@ -65,36 +31,6 @@ public JwtAuthenticationHandler( _ticketFactory = ticketFactory; } - public static AuthenticateResult OnMissingHeader(ILogger logger) - { - _logMissingHeader(logger, null); - return AuthenticateResult.NoResult(); - } - - public static AuthenticateResult OnIncorrectScheme(ILogger logger, string actualScheme, string expectedScheme) - { - _logIncorrectScheme(logger, actualScheme, expectedScheme, null); - return AuthenticateResult.NoResult(); - } - - public static AuthenticateResult OnEmptyHeader(ILogger logger, string header) - { - _logEmptyHeader(logger, null); - return AuthenticateResult.NoResult(); - } - - public static AuthenticateResult OnSuccessfulTicket(ILogger logger, AuthenticationTicket ticket) - { - _logSuccessfulTicket(logger, null); - return AuthenticateResult.Success(ticket); - } - - public static AuthenticateResult OnFailedTicket(ILogger logger, Exception ex) - { - _logFailedTicket(logger, ex.Message, ex); - return AuthenticateResult.Fail(ex); - } - protected override Task HandleAuthenticateAsync() { var header = this.Context.Request.Headers[HeaderNames.Authorization]; @@ -105,14 +41,14 @@ protected override Task HandleAuthenticateAsync() private AuthenticateResult GetAuthenticationResult(string header) { if (String.IsNullOrEmpty(header)) - return this.Options.OnMissingHeader(this.Logger); + return this.Events.MissingHeader(this.Logger); if (!header.StartsWith(this.Scheme.Name, StringComparison.OrdinalIgnoreCase)) - return this.Options.OnIncorrectScheme(this.Logger, header.Split(' ').FirstOrDefault(), this.Scheme.Name); + return this.Events.IncorrectScheme(this.Logger, header.Split(' ').FirstOrDefault(), this.Scheme.Name); var token = header.Substring(this.Scheme.Name.Length).Trim(); if (String.IsNullOrEmpty(token)) - return this.Options.OnEmptyHeader(this.Logger, header); + return this.Events.EmptyHeader(this.Logger, header); try { @@ -120,12 +56,24 @@ private AuthenticateResult GetAuthenticationResult(string header) var identity = _identityFactory.CreateIdentity(this.Options.PayloadType, payload); var ticket = _ticketFactory.CreateTicket(identity, this.Scheme); - return this.Options.OnSuccessfulTicket(this.Logger, ticket); + var successContext = new SuccessfulTicketContext(this.Logger, ticket, this.Context, this.Options); + return this.Events.SuccessfulTicket(successContext); } catch (Exception ex) { - return this.Options.OnFailedTicket(this.Logger, ex); + var failedContext = new FailedTicketContext(this.Logger, ex, this.Context, this.Options); + return this.Events.FailedTicket(failedContext); } } + + /// + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// If it is not provided, a default instance is supplied which does nothing when the methods are called. + /// + protected new JwtAuthenticationEvents Events + { + get => (JwtAuthenticationEvents)base.Events!; + set => base.Events = value; + } } -} \ No newline at end of file +} diff --git a/src/JWT.Extensions.AspNetCore/JwtAuthenticationOptions.cs b/src/JWT.Extensions.AspNetCore/JwtAuthenticationOptions.cs index 69d7a541..ae675936 100644 --- a/src/JWT.Extensions.AspNetCore/JwtAuthenticationOptions.cs +++ b/src/JWT.Extensions.AspNetCore/JwtAuthenticationOptions.cs @@ -8,6 +8,11 @@ namespace JWT.Extensions.AspNetCore { public class JwtAuthenticationOptions : AuthenticationSchemeOptions { + public JwtAuthenticationOptions() + { + Events = new JwtAuthenticationEvents(); + } + /// /// The keys used to sign the JWT. /// @@ -26,41 +31,64 @@ public class JwtAuthenticationOptions : AuthenticationSchemeOptions /// Handles missing authentication header. /// /// - /// For the default behavior . + /// For the default behavior . /// - public Func OnMissingHeader { get; set; } = JwtAuthenticationHandler.OnMissingHeader; + [Obsolete("Use Events.OnMissingHeader")] + public Func OnMissingHeader + { + get => Events.OnMissingHeader; + set => Events.OnMissingHeader = value; + } /// /// Handles incorrect authentication scheme. /// /// - /// For the default behavior . + /// For the default behavior . /// - public Func OnIncorrectScheme { get; set; } = JwtAuthenticationHandler.OnIncorrectScheme; + [Obsolete("Use Events.OnIncorrectScheme")] + public Func OnIncorrectScheme + { + get => Events.OnIncorrectScheme; + set => Events.OnIncorrectScheme = value; + } /// /// Handles empty authentication header. /// /// - /// For the default behavior . + /// For the default behavior . /// - public Func OnEmptyHeader { get; set; } = JwtAuthenticationHandler.OnEmptyHeader; + [Obsolete("Use Events.OnEmptyHeader")] + public Func OnEmptyHeader + { + get => Events.OnEmptyHeader; + set => Events.OnEmptyHeader = value; + } /// /// Handles successful authentication header. /// /// - /// For the default behavior . + /// For the default behavior . /// - public Func OnSuccessfulTicket { get; set; } = JwtAuthenticationHandler.OnSuccessfulTicket; + [Obsolete("Use Events.OnSuccessfulTicket")] + public Func OnSuccessfulTicket + { + set => Events.OnSuccessfulTicket = (context) => value(context.Logger, context.Ticket); + } /// /// Handles failed authentication header. /// /// - /// For the default behavior . + /// For the default behavior . /// - public Func OnFailedTicket { get; set; } = JwtAuthenticationHandler.OnFailedTicket; + [Obsolete("Use Events.OnFailedTicket")] + public Func OnFailedTicket + { + set => Events.OnFailedTicket = context => value(context.Logger, context.Exception); + } /// /// Whether to include by default AuthenticationScheme into the resulting . @@ -77,5 +105,15 @@ public class JwtAuthenticationOptions : AuthenticationSchemeOptions /// The default value is . /// public Type PayloadType { get; set; } = typeof(Dictionary); + + + /// + /// Custom Event Overrides + /// + public new JwtAuthenticationEvents Events + { + get => (JwtAuthenticationEvents)base.Events!; + set => base.Events = value; + } } -} \ No newline at end of file +} diff --git a/src/JWT.Extensions.AspNetCore/SuccessfulTicketContext.cs b/src/JWT.Extensions.AspNetCore/SuccessfulTicketContext.cs new file mode 100644 index 00000000..caf7fa74 --- /dev/null +++ b/src/JWT.Extensions.AspNetCore/SuccessfulTicketContext.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace JWT.Extensions.AspNetCore +{ + public class SuccessfulTicketContext + { + public SuccessfulTicketContext(ILogger logger, AuthenticationTicket ticket, HttpContext context, JwtAuthenticationOptions options) + { + this.Logger = logger; + this.Ticket = ticket; + this.Context = context; + this.Options = options; + } + + public ILogger Logger { get; } + + public AuthenticationTicket Ticket { get; } + + public HttpContext Context { get; } + + public JwtAuthenticationOptions Options { get; } + } +} diff --git a/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationEventsIntegrationTests.cs b/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationEventsIntegrationTests.cs new file mode 100644 index 00000000..7f4f0ee2 --- /dev/null +++ b/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationEventsIntegrationTests.cs @@ -0,0 +1,146 @@ +using System; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using JWT.Algorithms; +using JWT.Tests.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JWT.Extensions.AspNetCore.Tests +{ + [TestClass] + public class JwtAuthenticationEventsIntegrationTests + { + private static CancellationToken _cancellationToken; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _cancellationToken = context.CancellationTokenSource.Token; + } + + [TestMethod] + public async Task Request_Should_Fire_Events() + { + using var server = CreateServer(options => options.EventsType = typeof(MyEvents)); + + using var client = server.CreateClient(); + + // Arrange + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + JwtAuthenticationDefaults.AuthenticationScheme, + TestData.TokenByAsymmetricAlgorithm); + + // Act + using var response = await client.GetAsync("https://example.com/", _cancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + server.Services.GetRequiredService().HandledSuccessfulTicket.Should().BeTrue(); + } + + [TestMethod] + public async Task Backwards_Compat_Request_Should_Fire_Events() + { + bool backwardsCompat = false; + + using var server = CreateServer(options => options.OnSuccessfulTicket = (_, ticket) => + { + backwardsCompat = true; + return AuthenticateResult.Success(ticket); + }); + using var client = server.CreateClient(); + + // Arrange + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + JwtAuthenticationDefaults.AuthenticationScheme, + TestData.TokenByAsymmetricAlgorithm); + + // Act + using var response = await client.GetAsync("https://example.com/", _cancellationToken); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + backwardsCompat.Should().BeTrue(); + } + + private static TestServer CreateServer(Action configureOptions) + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseAuthentication(); + + app.Use(async (HttpContext context, Func _) => + { + var authenticationResult = await context.AuthenticateAsync(); + if (authenticationResult.Succeeded) + { + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = new ContentType("text/json").MediaType; + + await context.Response.WriteAsync("Hello"); + } + else + { + await context.ChallengeAsync(); + } + }); + }) + .ConfigureServices(services => + { + services.AddAuthentication(options => + { + // Prevents from System.InvalidOperationException: No authenticationScheme was specified, and there was no DefaultAuthenticateScheme found. + options.DefaultAuthenticateScheme = JwtAuthenticationDefaults.AuthenticationScheme; + + // Prevents from System.InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found. + options.DefaultChallengeScheme = JwtAuthenticationDefaults.AuthenticationScheme; + }) + .AddJwt(options => + { + options.Keys = TestData.Secrets; + options.VerifySignature = true; + configureOptions(options); + }); + + services.AddSingleton() + .AddSingleton() + .AddSingleton(new DelegateAlgorithmFactory(TestData.RS256Algorithm)); + }); + return new TestServer(builder); + } + + private sealed class MyEventsDependency + { + public bool HandledSuccessfulTicket { get; private set; } + + public void Set() => + this.HandledSuccessfulTicket = true; + } + + private sealed class MyEvents : JwtAuthenticationEvents + { + private readonly MyEventsDependency _dependency; + + public MyEvents(MyEventsDependency dependency) => + _dependency = dependency; + + public override AuthenticateResult SuccessfulTicket(SuccessfulTicketContext context) + { + _dependency.Set(); + return base.SuccessfulTicket(context); + } + } + } +} \ No newline at end of file diff --git a/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationHandlerIntegrationTests.cs b/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationHandlerIntegrationTests.cs index b4573c40..d0f21d55 100644 --- a/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationHandlerIntegrationTests.cs +++ b/tests/JWT.Extensions.AspNetCore.Tests/JwtAuthenticationHandlerIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; +using System.Threading; using System.Threading.Tasks; using AutoFixture; using FluentAssertions; @@ -21,6 +22,7 @@ namespace JWT.Extensions.AspNetCore.Tests [TestClass] public class JwtAuthenticationHandlerIntegrationTests { + private static CancellationToken _cancellationToken; private static readonly Fixture _fixture = new Fixture(); private static TestServer _server; @@ -29,6 +31,7 @@ public class JwtAuthenticationHandlerIntegrationTests [ClassInitialize] public static void ClassInitialize(TestContext context) { + _cancellationToken = context.CancellationTokenSource.Token; var options = new JwtAuthenticationOptions { Keys = TestData.Secrets, @@ -64,7 +67,7 @@ public async Task Request_Should_Return_Ok_When_Token_Is_Valid() TestData.TokenByAsymmetricAlgorithm); // Act - var response = await _client.GetAsync("https://example.com/"); + var response = await _client.GetAsync("https://example.com/", _cancellationToken); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -79,7 +82,7 @@ public async Task Request_Should_Return_Unauthorized_When_Token_Is_Empty() String.Empty); // Act - var response = await _client.GetAsync("https://example.com/"); + var response = await _client.GetAsync("https://example.com/", _cancellationToken); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -94,7 +97,7 @@ public async Task Request_Should_Return_Unauthorized_When_Token_Is_Invalid() TestData.TokenWithIncorrectSignature); // Act - var response = await _client.GetAsync("https://example.com/"); + var response = await _client.GetAsync("https://example.com/", _cancellationToken); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -109,7 +112,7 @@ public async Task Request_Should_Return_Unauthorized_When_AuthenticationScheme_I _fixture.Create()); // Act - var response = await _client.GetAsync("https://example.com/"); + var response = await _client.GetAsync("https://example.com/", _cancellationToken); // Assert response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); @@ -122,7 +125,7 @@ private static TestServer CreateServer(JwtAuthenticationOptions configureOptions { app.UseAuthentication(); - app.Use(async (HttpContext context, Func next) => + app.Use(async (HttpContext context, Func _) => { var authenticationResult = await context.AuthenticateAsync(); if (authenticationResult.Succeeded) @@ -130,7 +133,7 @@ private static TestServer CreateServer(JwtAuthenticationOptions configureOptions context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = new ContentType("text/json").MediaType; - await context.Response.WriteAsync("Hello"); + await context.Response.WriteAsync("Hello", _cancellationToken); } else { @@ -150,12 +153,11 @@ private static TestServer CreateServer(JwtAuthenticationOptions configureOptions }) .AddJwt(options => { - options.Keys = null; + options.Keys = configureOptions.Keys; options.VerifySignature = configureOptions.VerifySignature; }); services.AddSingleton(new DelegateAlgorithmFactory(TestData.RS256Algorithm)); }); - return new TestServer(builder); } }