diff --git a/Deveel.Webhooks.sln b/Deveel.Webhooks.sln index ac197cf..4b92118 100644 --- a/Deveel.Webhooks.sln +++ b/Deveel.Webhooks.sln @@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Model", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.XUnit", "test\Deveel.Events.Webhooks.XUnit\Deveel.Webhooks.XUnit.csproj", "{CF170566-4653-4E7A-AC54-5A1F7594B71F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver", "src\Deveel.Webhooks.Receiver\Deveel.Webhooks.Receiver.csproj", "{954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks", "src\Deveel.Webhooks\Deveel.Webhooks.csproj", "{6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Service", "src\Deveel.Webhooks.Service\Deveel.Webhooks.Service.csproj", "{D2A74CD1-202E-4F64-BAD8-88F37EB3805A}" @@ -23,7 +21,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{57F6404B-1FC EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{07F23FF6-2FE1-4072-BF37-9238E3750AA1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.XUnit", "test\Deveel.Webhooks.Receiver.XUnit\Deveel.Webhooks.Receiver.XUnit.csproj", "{4BC8323C-74F7-407A-8A5A-EA595B5C5585}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver.XUnit", "test\Deveel.Webhooks.Receiver.XUnit\Deveel.Webhooks.Receiver.XUnit.csproj", "{4BC8323C-74F7-407A-8A5A-EA595B5C5585}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Webhooks.Receiver.TestApi", "test\Deveel.Webhooks.Receiver.TestApi\Deveel.Webhooks.Receiver.TestApi.csproj", "{CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson", "src\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj", "{F23E99C7-8228-4AEE-894B-CAA303686239}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -39,10 +41,6 @@ Global {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Debug|Any CPU.Build.0 = Debug|Any CPU {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Release|Any CPU.ActiveCfg = Release|Any CPU {CF170566-4653-4E7A-AC54-5A1F7594B71F}.Release|Any CPU.Build.0 = Release|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4}.Release|Any CPU.Build.0 = Release|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Debug|Any CPU.Build.0 = Debug|Any CPU {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -67,6 +65,14 @@ Global {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Debug|Any CPU.Build.0 = Debug|Any CPU {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Release|Any CPU.ActiveCfg = Release|Any CPU {4BC8323C-74F7-407A-8A5A-EA595B5C5585}.Release|Any CPU.Build.0 = Release|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F}.Release|Any CPU.Build.0 = Release|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F23E99C7-8228-4AEE-894B-CAA303686239}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -74,13 +80,14 @@ Global GlobalSection(NestedProjects) = preSolution {9D710920-2A08-466C-94F0-3FD90E752A20} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {CF170566-4653-4E7A-AC54-5A1F7594B71F} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} - {954A1901-CD16-4DD0-A6F3-D0D7E7F1B9A4} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {6435EBAE-5E0A-446D-B4E4-D75FE89DFC99} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {D2A74CD1-202E-4F64-BAD8-88F37EB3805A} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {9796303D-5EFF-4942-A563-6B53C4FE6904} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {6E1CC992-53F1-4536-96C5-751C8AFBD015} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {69EA8584-6336-4A62-BE73-DE04DC6EE8E1} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} {4BC8323C-74F7-407A-8A5A-EA595B5C5585} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} + {CBAC02DA-EFF9-4458-99A2-DAF7ACFE607F} = {07F23FF6-2FE1-4072-BF37-9238E3750AA1} + {F23E99C7-8228-4AEE-894B-CAA303686239} = {57F6404B-1FCC-473F-A189-ABC9D640CC0E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E682A9F5-43D7-4D4C-82EA-953545B8F4DE} diff --git a/README.md b/README.md index 7d86146..ee8572e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Although this integration model is widely adopted by major service providers (li Anyway, a typical implementation consists of the following elements: * Webhooks are transported through _HTTP POST_ callbacks -* The webhook payload is represented as a JSON object (or alternatively as XML or Form) +* The webhook payload is formatted as a JSON object (or alternatively, in lesser common scenarios, as XML or Form) * The webhook payload includes properties that describe the type of event and the time-stamp of the occurrence * An optional signature in the header of the request or a query-string parameter ensures the authenticity of the caller diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureLocation.cs b/apl-2.licenseheader similarity index 79% rename from src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureLocation.cs rename to apl-2.licenseheader index 759d095..4ed22b3 100644 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureLocation.cs +++ b/apl-2.licenseheader @@ -1,4 +1,5 @@ -// Copyright 2022 Deveel +extensions: .cs +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,12 +12,3 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. - -using System; - -namespace Deveel.Webhooks { - public enum WebhookSignatureLocation { - Header, - QueryString - } -} diff --git a/docs/README.md b/docs/README.md index adc055d..ebfee56 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,9 +16,12 @@ # Deveel Webhooks Documentation +Here you can find a documentation of the `Deveel Webhooks` framework, to help you getting started with the libraries and functions that compose it and to understand how it works. + + ## Basic Concepts -| Concept | Description | +| Topic | Description | | ---------------------------------------------------- | ------------------------------------------ | | **[Webhook](concept_webhook.md)** | What is it a 'Webhook' and why I need it? | | **[Subscriptions](concept_webhook_subscription.md)** | How does a subscription to an event works? | @@ -32,6 +35,7 @@ | **[Basic Usage - Sending Webhooks](basic_usage_send.md)** | Manually sending webhooks (no subscriptions) | | **[Basic Usage - Subscription Management](basic_usage_management.md)** | Manage subscriptions to events (no sending) | | **[Basic Usage - Notify Webhooks](basic_usage_notify.md)** | Notify webhooks subscribers (management, transformations and sending) | +| **[Basic Usage - Receiving Webhooks](basic_usage_receive.md)** | Receive webhooks from external sources | ## Extending diff --git a/docs/basic_usage_receive.md b/docs/basic_usage_receive.md new file mode 100644 index 0000000..f59ce03 --- /dev/null +++ b/docs/basic_usage_receive.md @@ -0,0 +1,128 @@ +# Receive Webhooks from External Sources + +## Installations + +When receiving webhooks from external sources, you can use the `Deveel.Webhooks.Receiver.AspNetCore` library, that allows the registration of a webhook receiver in an ASP.NET Core application. + +To enable this function you must first install the NuGet package: + +```bash +dotnet add package Deveel.Webhooks.Receiver.AspNetCore +``` + + +## Registering the Webhook Receiver + +Then, in the `Startup` class of your application, you can register the webhook receiver as follows: + +```csharp +public class Startup { + public void ConfigureServices(IServiceCollection services) { + services.AddWebhooks(); + } +} +``` + +Or alternatively, if you are using the mininal API pattern, you can use the following code: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddWebhooks(); +``` + +This simple call registers the webhook receiver in the application, and allows to receive webhooks of type `MyWebhook`: receivers are segregated by the type of webhook they can handle, and you can register multiple receivers for different types of webhooks. + +By default the registration of the webhook receiver adds a set of default services, that are required to handle the webhooks, such as the `IWebhookReceiver`, `IWebhookHandler`, `IWebhookJsonParser` and a default set of options: you can control further the services and configurations by using the builder instance returned by the `AddWebhooks` method. + +## Receiving Webhooks - Using Controllers + +Following the registration of the webhook receiver, you can receive webhooks by using the `IWebhookReceiver` service, that is registered in the application, if you want to handle the receive process directly. + +This approach is typical in MVC APIs that implement the request processing in the controller, and can be used as follows: + +```csharp +namespace Demo { + [ApiController] + [Route("webhook")] + public class WebhookController : ControllerBase { + private readonly IWebhookReceiver webhookReceiver; + private readonly IWebhookHandler webhookHandler; + + public WebhookController(IWebhookReceiver webhookReceiver, IWebhookHandler webhookHandler) { + this.webhookReceiver = webhookReceiver; + this.webhookHandler = webhookHandler; + } + + [HttpPost] + public async Task ReceiveWebhook() { + var result = await webhookReceiver.ReceiveAsync(Request, HttpContext.RequestAborted); + if (!result.IsValid) + return BadRequest(result.Error); + + var webhook = result.Webhook; + await webhookHandler.HandleAsync(webhook, HttpContext.RequestAborted); + + return Ok(); + } + } +} +``` + +Mind that in the above scenario you must also inject the `IWebhookHandler` service, that is used to handle the received webhook. + +## Receiving Webhooks - Using Middleware + +Alternatively the `Deveel.Webhooks.Receiver.AspNetCore` library provides a middleware that can be used to receive webhooks, and handle them automatically. + +To use the middleware, you must first register it in the `Startup` class of your application: + +```csharp +public class Startup { + public void ConfigureServices(IServiceCollection services) { + services.AddWebhooks(); + } + +public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseWebhooks("/webhook"); + } +} +``` + +If you are using the minimal API pattern, you can use the following code: + +```csharp + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddWebhooks(); + +var app = builder.Build(); + +app.UseWebhooks("/webhook"); + +app.Run(); +``` + +The above code registers the middleware in the application, and allows to receive webhooks of type `MyWebhook` at the `/webhook` endpoint, using the configurations defined when registering the receiver, + +The middlware will automatically scan for all the registered webhook receivers, and will handle the received webhooks by using the `IWebhookHandler` service. + +The middleware design allows to handle the webhooks without any prior registered handler, by specifying an handling delegate in the `UseWebhooks` method: + +```csharp +[...] + +app.UseWebhooks("/webhook", (context, webhook, cancellationToken) => { + // Handle the webhook here +}); +``` + +Or a alternatively a synchronous handling delegate: + +```csharp +[...] + +app.UseWebhooks("/webhook", (context, webhook) => { + // Handle the webhook here +}); +``` \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index df4c675..deeb5d5 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -16,7 +16,7 @@ # Getting Started -The overall design of this little framework is open and extensible (implementing the traditional [Open-Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle)), that means base contracts can be extended, composed or replaced. +The overall design of this framework is open and extensible (implementing the traditional [Open-Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle)), that means base contracts can be extended, composed or replaced. It is possible to use its components as they are provided, or use the base contracts to extend single functions, while still using the rest of the provisioning. @@ -38,13 +38,13 @@ Or by editing your `.csproj` file and adding a `` entry. - netcoreapp3.1 + ne5.0 ... - + ... @@ -56,17 +56,17 @@ This provides all the functions that are needed to send webhooks to a given dest The libraries currently provided by the framework are the following: -| Library | Description | NuGet | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| **Deveel.Webhooks.Model** | The foundation library that defines the webhooks information model | [Package](https://www.nuget.org/packages/Deveel.Webhooks.Model/) | -| **Deveel.Webhooks** | Provides the foundation contracts of the webhook service and basic implementations for the sending functions | [Package](https://www.nuget.org/packages/Deveel.Webhooks/) | -| **Deveel.Webhooks.Service** | Implements the functions to manage and resolve webhook subscriptions | [Package](https://www.nuget.org/packages/Deveel.Webhooks.Service/) | -| **Deveel.Webhooks.Service.MongoDb** | An implementation of the webhoom management data layer that is backed by [MongoDB](https://mongodb.com) databases | [Package](https://www.nuget.org/packages/Deveel.Webhooks.MongoDb/) | -| **Deveel.Webhooks.DynamicLinq** | The webhook subscription filtering engine that uses the [Dynamic LINQ](https://dynamic-linq.net/) expressions | [Package](https://www.nuget.org/packages/Deveel.Webhooks.DynamicLinq/) | +| Library | Description | NuGet | GitHub (prerelease) | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |---------------------| +| **Deveel.Webhooks** | Provides the foundation contracts of the webhook service and basic implementations for the sending functions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks?label=Deveel.Webhooks&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks) | +| **Deveel.Webhooks.Service** | Implements the functions to manage and resolve webhook subscriptions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.Service?label=Deveel.Webhooks.Service&logo=nuget)| [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.Service&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.Service) | +| **Deveel.Webhooks.MongoDb** | An implementation of the webhoom management data layer that is backed by [MongoDB](https://mongodb.com) databases | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.MongoDb?label=Deveel.Webhooks.MongoDb&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.MongoDb&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.MongoDb) | +| **Deveel.Webhooks.DynamicLinq** | The webhook subscription filtering engine that uses the [Dynamic LINQ](https://dynamic-linq.net/) expressions | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks.DynamicLinq?label=Deveel.Webhooks.DynamicLinq&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.DynamicLinq&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.DynamicLinq) | +| **Deveel.Webhooks.Receiver.AspNetCore** | An implementation of the webhook receiver that is backed by [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet) | ![Nuget](https://img.shields.io/nuget/dt/Deveel.Webhooks?label=Deveel.Webhooks.Receiver.AspNetCore&logo=nuget) | [![GitHub](https://img.shields.io/static/v1?label=Deveel.Webhooks.Receiver.AspNetCore&message=Pre-Release&color=yellow&logo=github)](https://github.com/deveel/deveel.webhooks/pkgs/nuget/Deveel.Webhooks.Receiver.AspNetCore) | You can obtain the stable versions of these libraries from the [NuGet Official](https://nuget.org) channel. -For the _nighly builds_ and previews you can restore from the [Deveel Package Manager](https://github.com/orgs/deveel/packages). +To get the latest pre-release versions of the packages you can restore from the [Deveel Package Manager](https://github.com/orgs/deveel/packages). ## Adding the Webhook Service diff --git a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj index 638c3c7..4e869d3 100644 --- a/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj +++ b/src/Deveel.Webhooks.DynamicLinq/Deveel.Webhooks.DynamicLinq.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS An engine of the Deveel Webhooks framework that uses the Dynamic LINQ expressions to evaluate filters - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks @@ -22,7 +22,7 @@ - + True @@ -33,7 +33,11 @@ - + + + + + diff --git a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj index 431aff0..6bf63c6 100644 --- a/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj +++ b/src/Deveel.Webhooks.Model/Deveel.Webhooks.Model.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions defining the model of the Webhook domain within the Deveel Webhooks Framework - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png @@ -29,4 +29,8 @@ + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson new file mode 100644 index 0000000..ec181dd --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson @@ -0,0 +1,50 @@ + + + + Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson + + + + + Implements a that uses the + Newtonsoft.Json library to parse the webhook. + + + + + + Initializes a new instance of the + + An optional set of configurations that control the + behavior of the JSON serialization. When this is not provided, an instance + of is created to provide + the default settings to the serializer. + + + + Gets the settings used to configure the JSON serialization + + + + + + + + Extends the to registering + the parser for the webhook payload using the Newtonsoft.Json library. + + + + + Registers the as + a parser for webhook payloads. + + The type of webhook to parse + The builder object used to configure a webhook receiver + A set of settings to configure the JSON serialization process + + Returns the instance with the parser registered + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj new file mode 100644 index 0000000..7d2a5c4 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson.csproj @@ -0,0 +1,45 @@ + + + + net6.0 + enable + enable + 1.1.7 + Deveel + true + antonello + Deveel AS + Copyright (C) 2021-2023 Deveel AS + LICENSE + deveel-logo.png + https://github.com/deveel/deveel.webhooks + git + webhooks receiver receivers aspnet core aspnetcore httpcontext httprequest newtonsoft json jsonparser + Extends the ASP.NET Core receivers with webhook parsers implemented using the Newtonsoft.Json library + https://deveel.com + .\Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson + + + + + True + + + + True + + + + + + + + + + + + + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs new file mode 100644 index 0000000..009430c --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/NewtonsoftWebhookJsonParser.cs @@ -0,0 +1,55 @@ + +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks { + /// + /// Implements a that uses the + /// Newtonsoft.Json library to parse the webhook. + /// + /// + public sealed class NewtonsoftWebhookJsonParser : IWebhookJsonParser { + /// + /// Initializes a new instance of the + /// + /// An optional set of configurations that control the + /// behavior of the JSON serialization. When this is not provided, an instance + /// of is created to provide + /// the default settings to the serializer. + public NewtonsoftWebhookJsonParser(JsonSerializerSettings? settings = null) { + JsonSerializerSettings = settings ?? new JsonSerializerSettings(); + } + + /// + /// Gets the settings used to configure the JSON serialization + /// + public JsonSerializerSettings JsonSerializerSettings { get; } + + /// + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + try { + using var textReader = new StreamReader(utf8Stream, Encoding.UTF8); + var json = await textReader.ReadToEndAsync(); + + return JsonConvert.DeserializeObject(json, JsonSerializerSettings); + } catch (Exception ex) { + throw new WebhookParseException("Could not parse the stream to a webhook", ex); + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs new file mode 100644 index 0000000..2729551 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore.NewtonsoftJson/Webhooks/WebhookReceiverBuilderExtensions.cs @@ -0,0 +1,46 @@ + +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.DependencyInjection; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks { + /// + /// Extends the to registering + /// the parser for the webhook payload using the Newtonsoft.Json library. + /// + public static class WebhookReceiverBuilderExtensions { + /// + /// Registers the as + /// a parser for webhook payloads. + /// + /// The type of webhook to parse + /// The builder object used to configure a webhook receiver + /// A set of settings to configure the JSON serialization process + /// + /// Returns the instance with the parser registered + /// + public static WebhookReceiverBuilder UseNewtonsoftJsonParser(this WebhookReceiverBuilder builder, JsonSerializerSettings? settings = null) + where TWebhook : class { + + builder.Services.AddSingleton>(_ => new NewtonsoftWebhookJsonParser(settings)); + builder.Services.AddSingleton(_ => new NewtonsoftWebhookJsonParser(settings)); + + + return builder; + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj index 6c75d78..333fc74 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.csproj @@ -1,40 +1,45 @@ - - net6.0 - 1.1.6 - Deveel - true - antonello - Deveel - Copyright (C) 2021-2022 Deveel - LICENSE - deveel-logo.png - https://github.com/deveel/deveel.webhooks - git - webhooks receiver receivers aspnet core httprequest - Provides an implementation of the webhook receivers supporting the ASP.NET Core infrastructure - https://deveel.com - + + net6.0 + enable + enable + 1.1.7 + Deveel + true + antonello + Deveel AS + Copyright (C) 2021-2023 Deveel AS + LICENSE + deveel-logo.png + https://github.com/deveel/deveel.webhooks + git + webhooks receiver receivers aspnet core httprequest + Provides an implementation of the webhook receivers supporting the ASP.NET Core infrastructure + https://deveel.com + .\Deveel.Webhooks.Receiver.AspNetCore.xml + true + - - - - + + + + + + - - - True - - - - True - - - - - - - + + + True + + + + True + + + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml new file mode 100644 index 0000000..26d39f8 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Deveel.Webhooks.Receiver.AspNetCore.xml @@ -0,0 +1,1276 @@ + + + + Deveel.Webhooks.Receiver.AspNetCore + + + + + Extends a object to register + a receiver of a specific type of webhooks. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + The type of webhooks to receive + + The service collection to which the receiver is added + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + The path to the configuration section that contains the options for the webhook receiver + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + A configuraton action that can be used to further configure the receiver + + + Returns an instance of that can + be used to further configure the receiver. + + + + + Adds a receiver of webhooks of a specific type to the service collection. + + + The type of webhooks to receive + + + The service collection to which the receiver is added + + + A configuraton action that can be used to further configure the receiver + + + Returns an instance of that can be used to register + other services and configurations. + + + + + Extends the to provide methods + for receiving webhooks within an ASP.NET Core application request pipeline. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The relative path to listen for webhook posts + + + The middleware will listen only for POST requests to the given path using + the configurations and services registered in the application. + + + Before this middleware can be used, the webhook receiver must be registered + during the application startup. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Adds a middleware to the application pipeline that provides a verification + mechanism for the webhook requests. + + The type of the webhook + The application builder instance + The HTTP method to listen for requests + The relative path to listen for verification requests + + + Some service providers require a verification of the webhook requests before + posting the webhook to the receiver: this middleware provides a mechanism to + handle such requests and respond. + + + If the provider does not require a verification, this middleware can be ignored, + and it will not affect the normal operation of the webhook receiver. + + + + Returns an instance of the that handles + the verification requests. + + + + + Adds a middleware to the application pipeline that provides a verification + mechanism for the webhook requests. + + The type of the webhook + The application builder instance + The relative path to listen for verification requests + + + By default this middleware will listen for GET requests to the given path. + + + Some service providers require a verification of the webhook requests before + posting the webhook to the receiver: this middleware provides a mechanism to + handle such requests and respond. + + + If the provider does not require a verification, this middleware can be ignored, + and it will not affect the normal operation of the webhook receiver. + + + + Returns an instance of the that handles + the verification requests. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The path to listen for webhook posts + The delegated function that is invoked by the middleware + to handle the received webhook + + + The middleware will listen only for POST requests to the given path using + the configurations registered at the application startup. + + + Any instance of the registered will + be ignored when using this middleware, and only the provided function will be + invoked by the middleware. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Adds a middleware to the application pipeline that receives webhooks + of that are posted to the given path. + + The type of the webhook to be received + The application builder instance + The path to listen for webhook posts + The delegated function that is invoked by the middleware + to handle the received webhook + + + The middleware will listen only for POST requests to the given path using + the configurations registered at the application startup. + + + Any instance of the registered will + be ignored when using this middleware, and only the provided function will be + invoked by the middleware. + + + + Returns the instance of the that handles + webhooks posted to the given path. + + + + + Provides functions for handling webhooks of a specific type. + + The type of the webhook to be handled + + + The typical usage scenario of usage of services implementing this interface + is within ASP.NET receiver middlewares that are registered in the pipeline, + and resolve all compatible handlers to notify a webhook has been received by the + application. + + + It is recommended that the implementation of this interface performs a rapid + handling of the webhook, and then delegates the actual processing to a background + or external process, to avoid blocking the pipeline. + + + + + + Handles the given webhook. + + The instance of the webhook to be handled + + + Returns a that completes when the webhook has been handled. + + + + + Provides the capabilities to parse a webhook from a JSON stream. + + The type of the webhook to be parsed + + + + Parses a webhook from the given UTF-8 encoded stream. + + The UTF-8 stream that represents the binary + data of a JSON-formatted webhook + + + Returns a that completes when the webhook + stream is parsed and produces the instance of the webhook. + + + Thrown if any error occurs while parsing the webhook stream. + + + + + A service that receives a webhook from a remote source. + + The type of the webhook that is received + + + + Receives a webhook from a remote source, posted through a + HTTP request given. + + The HTTP request that transports the webhook to be received + + + + Implementations of this contract should read the content of the request and + parsing it into a webhook instance of the type . + + + Optionally the implementation may also validate the signature of the request, + to ensure that the webhook is coming from a trusted source: this is not mandatory + but highly recommended. Verification of the signatures of webhook payloads might + affect performances, since the typical implementation of signers use strong hashing + algorithms. + + + + Returns a that completes when the webhook is received + + + + + A service that is used to verify a request of acknowledgement + by the sender of a webhook, before the webhook is sent. + + + The type of webhook that is being verified + + + + In several case scenarios, providers of webhooks require a verification + of the party to ensure they are the ones who should be receiving the + webhooks, and not a malicious party. + + + The process of verification is usually a two-step process, where the + request is first validated, and then the instance of the result of the + validation is passed back to the verifier to handle the result towards + the sender: this mechanism is used to ensure that the verification process + is specific for the provider of the webhook, since the different methodologies + implemented by various providers. + + + + + + Verifies the request of acknowledgement of a webhook. + + + The HTTP request that is carrying the information + to acknowledge the webhook. + + + A token that can be used to cancel the operation + + + Returns a that indicates the result + of the verification operation. + + + + + Handles the result of the verification of a webhook request. + + The result of the verification that should be handled + The HTTP response used to notify the sender + + A token that can be used to cancel the operation + + + Returns a that completes when the result is handled. + + + + + Provides functions for the signing of a webhook payload + + + + + Gets the list of algorithms supported by this signer + + + + + Signs the given JSON body using the provided secret + as a key for the signature + + The JSON-formatted body of a webhook to sign + The secret used as a key for the signature + + A typical implementation of this method would return a string that + contains the signature, prefixed by the algorithm used to sign the + webhook in the format [algorithm]=[signature]. + + + Returns a string representing the signature of the given body + + + + + Implements a provider of instances + for given algorithms. + + The type of webhook to provide signers for + + + + Gets the signer for the given algorithm. + + The name of the algorithm handled by + the signer to lookup for + + Returns an instance of that supports + the given algorithm, or null if no such signer is available. + + + + + Defines a contract for a service that can verify the signature of a + specific type of webhook. + + The type of webhook to sign + + + Webhook signers are typically implementing the same behavior, + and this contract is a way to define a constraint usage of the signer + within a receiver context. + + + In some advanced scenarios, it is possible to have multiple signers + for the same algorithm but specific for a given type of webhook, according + to the different needs of the provider. + + + + + + + Represents the result of a verification of a webhook request. + + + This contract is used by implementations of + to proceed with a two-step verification of a webhook request. + + + + + Gets whether the request is verified or not. + + + + + Extends the interface + to provide standard methods to retrieve the options for a specific + webhook receiver. + + + + + Gets the options for the webhook receiver of the given type. + + The type of webhook handled by the receiver + The instance of the to extend + + Returns the options for the receiver of the given type. + + + + + Gets the options for the webhook verifier of the given type. + + + The type of webhook handled by the verifier + + + The instance of the to extend + + + Returns the options for the verifier of the given type. + + + + + A default implementation of the that handles + a typical webhook signature using the SHA-256 algorithm. + + + + + + + + Computes the hash of the given using the + provided secret, + + The JSON-formatted string that represents the webhook to sign + A secret used as key for the signature + + Returns a byte array representing the hash of the given body + + + Thrown when the is null or empty + + + + + Gets the string representation of the signature, given the hash + + The byte hash of the signature + + Returns a string representing the signature of the given body + + + + + + + + Provides a default implementation of the + that is using the System.Text.Json library for parsing the JSON + representations of webhooks. + + The type of the webhook to parse + + + + Initializes a new instance of the + + A set of options to control the behavior of the serialization + + When the is not provided, a new instance of the + is created with the + default configurations. + + + + + Gets the options used to control the behavior of the serialization + + + + + + + + Extends the with + methods for the parsing of a webhooks. + + + + + Parses a webhook from the given string. + + The type of the webhook to be parsed + The instance of the to extend + The UTF-8 encoded JSON-formatted string to be parsed + + + Returns a that resolves to the parsed webhook + + + Thrown if any error occurs while parsing the webhook + + + + + An exception thrown when a webhook cannot be parsed. + + + + + + + + + + + + + + A default implementation of the + that uses the registered options and services to receive a webhook. + + The type of the webhook to receive + + + This class implements a default behavior for the , + that is based on common patterns for the processing of webhooks. + + + It is recommended to inherit from this class to implement a custom receiver behavior, + when the default behavior is not sufficient. In some case scenarios, it is recommended + to discard the possibility of using this class and implement the . + + + + + + Constructs a instance. + + An instance of the that is + used to resolve the configurations specific for this receiver. + A provider of services that + are used to verify the signature of webhooks + A parser that is used to process the JSON + content of requests and obtain instances of webhooks. By default, if this + value is null a new instance of + is created using the default options. + + + + Constructs a instance. + + The configurations used by the receiver to + process the requests + A parser that is used to process the JSON + content of requests and obtain instances of webhooks. By default, if this + value is null a new instance of + is created using the default options. + + Thrown if the given is null + + + + + Gets the options used by the receiver to process the requests. + + + + + Gets the parser used to process the JSON content of requests + + + + + Resolves a webhook signer for the given algorithm. + + The hashing algorithm used to sign the webhook + + Returns an instance of that is used to + sign the webhook, or null if no signer is available for the + given algorithm. + + + + + Signs the JSON body of a webhook using the given algorithm and secret. + + The JSON-formatted representation of a webhook + The hashing algorithm used to sign the webhook + A secret word used to compute the signature + + Returns a string that is the signature of the given JSON body, or null + if no signer is available for the given algorithm. + + + + + Parses the JSON body of a webhook request. + + The JSON-formatted body of the webhook to be parsed + + + Returns an instance of that completes the + parsing operation to obtain the webhook. + + + Thrown if the parsing operation is not supported by the receiver. + + + + + Parses the JSON body of a webhook request. + + A stream that is UTF-8 encoded and that provides the + body of the webhook to be parsed + + + Returns an instance of that completes the + parsing operation to obtain the webhook. + + + Thrown if the parsing operation is not supported by the receiver. + + + + + Attempts to get the signature from the given request. + + The HTTP request from the sender of the webhook + that should include a signature + The signature of the webhook discovered from within + the request + + By default this method verifies if the configuration of the receiver + explicitly requires or forbids the verification of signatures: in the + cases the receiver is configured not to verify signatures, this method + will return false even if the signature is present in the request. + + + Returns true if the signature was found in the request, or false otherwise. + + + + + Verifies if the given signature sent alongside a webhook is + valid for the given JSON body of the webhook itself. + + The signature sent alongside the webhook + The signing hash algorithm used to compute the signature + The JSON-formatted body of the webhook + + + The default behavior of this method is to return true if the verification + of the signature is disabled in the configuration of the receiver. + + + To verify the signature, this method will use the secret word configured as a key + to compute the signature of the given JSON body, and then compare it with the one + sent alongside the webhook. + + + + Returns true if the signature is valid for the given webhook, + or false otherwise. + + + + + Attempts to validate the webhook request. + + The HTTP request used to post the webhook + + Returns a that describes the result of the validation. + + + + + + + + Describes the result of a validation attempt. + + + + + Indicates if the signature was actually validated. + + + + + Indicates if the signature was valid, or null if the + signature was not validated. + + + + + Gets the JSON body of the webhook, or null if it was + not possible to read it from the request. + + + + + Initializes a new instance of the struct. + + The JSON-formatted string that represents the webhook + Indicates if the webhook signature was actually validated + Indicates if the webhook signature was valid + + + + An object that can be used to configure a receiver of webhooks + + The type of webhooks to receive + + When constructing the builder a set of default services are registered, + such as the middleware for the receiver and the verifier, a default JSON + parser and the default receiver service. + + + + + Initializes a new instance of the class + + + The service collection to which the receiver is added + + + Thrown if the type is not a non-abstract class + + + Thrown if the argument is null + + + + + Constructs a new instance of the class + instantiating a new service collection + + + + + Gets the service collection to which the receiver is added + + + + + Registers an implementation of the + that is used to receive the webhooks + + + The type of the receiver to use for the webhooks of type + + + Returns the current builder instance with the receiver registered + + + + + Registers an implementation of the + that is used to verify the webhooks verification requests from + senders + + + The type of the verifier to use for the webhooks of type + + + Returns the current builder instance with the verifier registered + + + + + Registers the default implementation of the + + + A delegate that can be used to configure the options for the verifier + + + Returns the current builder instance with the verifier registered + + + + + + Registers the default implementation of the + + + The path to the section in the configuration that contains the options + + + Return the current builder instance with the verifier registered + + + + + Registers an handler for the webhooks of type + that were received. + + + The type of the handler to use for the webhooks of type + + + Returns the current builder instance with the handler registered + + + + + Configures the receiver with the options from the given section path + within the configuration of the application + + + The path to the section within the configuration of the application + where the options are defined + + + Returns the current builder instance with the options configured + + + + + Configures the receiver with the given options + + + A delegate that is used to configure the options of the receiver + + + Returns the current builder instance with the options configured + + + + + Registers a parser that is used to parse the JSON body of webhooks received + + + The type of the parser to use for the webhooks of type + + + A value that specifies the lifetime of the parser service (defaults to ) + + + Returns the current builder instance with the parser registered + + + + + Registers a parser that is used to parse the JSON body of webhooks received + + + The type of the parser to use for the webhooks of type + + + An instance of the parser to use for the webhooks of type + + + Returns the current builder instance with the parser registered + + + + + Registers a default parser that is used to parse the JSON body of webhooks received + + + An optional set of options that are used to configure the JSON parser behavior + + + Returns the current builder instance with the parser registered + + + + + Registers a function as parser that is used to parse the JSON body of webhooks received + + + The function that is used to parse the JSON body of webhooks received + + + Returns the current builder instance with the parser registered + + + Thrown when the given is null + + + + + Registers a function as parser that is used to parse the JSON body of webhooks received + + + The function that is used to parse the JSON body of webhooks received + + + Returns the current builder instance with the parser registered + + + Thrown when the given is null + + + + + Registers a service that is used to sign the payload of webhooks received + + + The type of the signer to use for the webhooks of type + + + Returns the current builder instance with the signer registered + + + + + Registers the default implementation of that is used + to sign the payload of webhooks received with a SHA256 hash + + + Returns the current builder instance with the signer registered + + + + + Registers a service that is used to sign the payload of webhooks received + + + The type of the signer to use for the webhooks of type + + + The instance of the signer to use for the webhooks of type + + + Returns the current builder instance with the signer registered + + + Thrown when the given is null + + + + + Describes the result of a webhook receive operation. + + + The type of webhook that was received + + + When this object is returned from a webhook receiver, it can be used + to determine if the webhook was successfully received and if the signature was valid. + + + + + Constructs a new result of a webhook receive operation. + + The webhook instance that was received, or null + if it was not possible to receive the webhook for any reason (invalid content, + missing or invalid signature, etc.) + + Whether the signature of the webhook was valid, or null if the signature + was not checked. + + + + + Gets the webhook instance that was received, or null if it was not + possible to receive the webhook for any reason (invalid content, missing or + invalid signature, etc.). + + + + + Gets whether the signature of the webhook was valid, or null if the + signature was not checked. + + + + + Implicitly converts a to a + successful result with the given webhook instance. + + + The webhook instance that was received + + + + + Gets whether the signature of the webhook was validated. + + + + + Gets whether the webhook was successfully received. + + + + + Gets whether the webhook was received but the signature was invalid. + + + + + An exception thrown when an error occurs during the processing of a webhook + + + + + + + + + + + + + + Provides the configuration options for a webhook receiver. + + + + + Gets or sets whether the signature of the incoming webhook + should be verified. + + + + + Gets or sets the options for the signature verification. + + + + + Gets or sets the HTTP status code to return when the webhook + processing is successful (201 by default). + + + + + Gets or sets the HTTP status code to return when the webhook + processing failed for an internal error (500 by default). + + + + + Gets or sets the HTTP status code to return when the webhook + from the sender is invalid (400 by default). + + + + + A default implementation of a verifier of a webhook request that performs + a simple check for a token in the request matching one configured. + + + The type of webhook that is being verified + + + + + Constructs a instance with a + selector that resolves the options for the given type of webhook. + + + The provider of the options for the verification of the webhook request + + + + + Constructs a instance with the given options + + + + + + + Gets the options for the verification of the webhook request + + + + + Responds to the sender with a successful verification of the request. + + + The result of the verification of the request + + + The HTTP response object used to respond to the sender + + + A token that can be used to cancel the operation + + + Returns a that completes when the response is sent + + + + + Responds to the sender with a failed verification of the request. + + + The failed result of the verification of the request + + + The HTTP response object used to respond to the sender + + + A token that can be used to cancel the operation + + + Returns a that completes when the response is sent + + + + + + + + Tries to get the verification token from the given request. + + + The HTTP request object that carries the data used for the verification + + + A string that contains the token, if found in the request + + + Returns true if the token is found in the request, or false otherwise + + + + + + + + Enumerates the possible locations where the signature of a webhook + can be found within a HTTP request object. + + + + + The signature is found in the HTTP header of the request. + + + + + The signature is found in the query string of the request. + + + + + Provides the configuration settings used to verify the signature + of a webhook sent to the receiver. + + + + + Gets or sets the location where the signature is found ( by default). + + + + + Gets or sets the name of the parameter that contains the signature. + + + + + Gets or sets the type of algorithm used to compute the signature. + + + + + Gets or sets the secret used to compute the signature. + + + + + Gets or sets the HTTP status code to return when the webhook + signature is invalid (400 by default). + + + + + Provides the configuration options for the default verification + of a webhook request. + + + + + Gets or sets a token that is matched against the value + sent by the provider to verify the identity of the receiver. + + + + + Gets or sets the name of the query parameter that contains + the verification token. + + + + + Gets or sets the HTTP status code to return when the request + is successfully verified (204 by default). + + + + + Gets or sets the HTTP status code to return when the request + is not authenticated (403 by default). + + + + + Represents a default implementation of a result + of the verification of a webhook request. + + + + + Constructs the result of a verification of a webhook request. + + + Whether the request is verified or not + + + + + Gets whether the request is verified or not. + + + + + Creates a new result of a successful verification of a webhook request + + + Returns an instance of that + represents a successful verification of a webhook request. + + + + + Creates a new result of a failed verification of a webhook request + + + Returns a new instance of that + represents a failed verification of a webhook request. + + + + diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..9a878e6 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/ServiceCollectionExtensions.cs @@ -0,0 +1,112 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +using Deveel.Webhooks; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deveel { + /// + /// Extends a object to register + /// a receiver of a specific type of webhooks. + /// + public static class ServiceCollectionExtensions { + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// The type of webhooks to receive + /// + /// The service collection to which the receiver is added + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services) + where TWebhook : class { + var builder = new WebhookReceiverBuilder(services); + + services.TryAddSingleton(builder); + + return builder; + } + + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// The path to the configuration section that contains the options for the webhook receiver + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services, string sectionPath) + where TWebhook : class + => services.AddWebhooks().Configure(sectionPath); + + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// A configuraton action that can be used to further configure the receiver + /// + /// + /// Returns an instance of that can + /// be used to further configure the receiver. + /// + public static WebhookReceiverBuilder AddWebhooks(this IServiceCollection services, Action configure) + where TWebhook : class + => services.AddWebhooks().Configure(configure); + + /// + /// Adds a receiver of webhooks of a specific type to the service collection. + /// + /// + /// The type of webhooks to receive + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// A configuraton action that can be used to further configure the receiver + /// + /// + /// Returns an instance of that can be used to register + /// other services and configurations. + /// + public static IServiceCollection AddWebhooks(this IServiceCollection services, Action> configure) + where TWebhook : class { + var builder = services.AddWebhooks(); + configure?.Invoke(builder); + + return services; + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..1b04ad4 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/ApplicationBuilderExtensions.cs @@ -0,0 +1,177 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + /// + /// Extends the to provide methods + /// for receiving webhooks within an ASP.NET Core application request pipeline. + /// + public static class ApplicationBuilderExtensions { + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The relative path to listen for webhook posts + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations and services registered in the application. + /// + /// + /// Before this middleware can be used, the webhook receiver must be registered + /// during the application startup. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>() + ); + } + + /// + /// Adds a middleware to the application pipeline that provides a verification + /// mechanism for the webhook requests. + /// + /// The type of the webhook + /// The application builder instance + /// The HTTP method to listen for requests + /// The relative path to listen for verification requests + /// + /// + /// Some service providers require a verification of the webhook requests before + /// posting the webhook to the receiver: this middleware provides a mechanism to + /// handle such requests and respond. + /// + /// + /// If the provider does not require a verification, this middleware can be ignored, + /// and it will not affect the normal operation of the webhook receiver. + /// + /// + /// + /// Returns an instance of the that handles + /// the verification requests. + /// + public static IApplicationBuilder UseWebhookVerifier(this IApplicationBuilder app, string method, string path) + where TWebhook : class + => app.MapWhen( + context => context.Request.Method == method && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>() + ); + + /// + /// Adds a middleware to the application pipeline that provides a verification + /// mechanism for the webhook requests. + /// + /// The type of the webhook + /// The application builder instance + /// The relative path to listen for verification requests + /// + /// + /// By default this middleware will listen for GET requests to the given path. + /// + /// + /// Some service providers require a verification of the webhook requests before + /// posting the webhook to the receiver: this middleware provides a mechanism to + /// handle such requests and respond. + /// + /// + /// If the provider does not require a verification, this middleware can be ignored, + /// and it will not affect the normal operation of the webhook receiver. + /// + /// + /// + /// Returns an instance of the that handles + /// the verification requests. + /// + public static IApplicationBuilder UseWebhookVerfier(this IApplicationBuilder app, string path) + where TWebhook : class + => app.UseWebhookVerifier("GET", path); + + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The path to listen for webhook posts + /// The delegated function that is invoked by the middleware + /// to handle the received webhook + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations registered at the application startup. + /// + /// + /// Any instance of the registered will + /// be ignored when using this middleware, and only the provided function will be + /// invoked by the middleware. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Func receiver) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>(receiver) + ); + } + + /// + /// Adds a middleware to the application pipeline that receives webhooks + /// of that are posted to the given path. + /// + /// The type of the webhook to be received + /// The application builder instance + /// The path to listen for webhook posts + /// The delegated function that is invoked by the middleware + /// to handle the received webhook + /// + /// + /// The middleware will listen only for POST requests to the given path using + /// the configurations registered at the application startup. + /// + /// + /// Any instance of the registered will + /// be ignored when using this middleware, and only the provided function will be + /// invoked by the middleware. + /// + /// + /// + /// Returns the instance of the that handles + /// webhooks posted to the given path. + /// + public static IApplicationBuilder UseWebhookReceiver(this IApplicationBuilder app, string path, Action receiver) + where TWebhook : class { + return app.MapWhen( + context => context.Request.Method == "POST" && context.Request.Path.Equals(path), + builder => builder.UseMiddleware>(receiver) + ); + } + + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs deleted file mode 100644 index 409c2f2..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/DefaultWebhookReceiver.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public class DefaultWebhookReceiver : IWebhookReceiver where T : class { - private readonly Action afterRead; - - public DefaultWebhookReceiver(IOptions options, Action afterRead) - : this(options?.Value, afterRead) { - } - - public DefaultWebhookReceiver(IOptions options) - : this(options, null) { - } - - public DefaultWebhookReceiver(WebhookReceiveOptions options, Action afterRead) { - Options = options; - this.afterRead = afterRead; - } - - public DefaultWebhookReceiver(WebhookReceiveOptions options) - : this(options, null) { - } - - public DefaultWebhookReceiver() - : this(new WebhookReceiveOptions()) { - } - - protected WebhookReceiveOptions Options { get; } - - protected virtual void OnAfterRead(JObject json, T obj) { - afterRead?.Invoke(json, obj); - } - - public Task ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { - return request.GetWebhookAsync(Options, OnAfterRead, cancellationToken); - } - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs deleted file mode 100644 index 670da9f..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/HttpRequestExtensions.cs +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Threading.Tasks; -using System.Threading; -using System.Web; -using System.Xml.Linq; - -using Microsoft.AspNetCore.Http; - -using Newtonsoft.Json.Linq; -using System.IO; -using System.Text; -using System.Linq; -using Newtonsoft.Json; -using System.Net.Http; - -namespace Deveel.Webhooks { - public static class HttpRequestExtensions { - public static string GetCharset(this HttpRequest request) { - var contentType = request.ContentType; - if (String.IsNullOrWhiteSpace(contentType)) - return null; - - var parts = contentType.Split(';', StringSplitOptions.RemoveEmptyEntries); - var charsetPart = parts.FirstOrDefault(x => x.StartsWith("chartset")); - if (String.IsNullOrWhiteSpace(charsetPart)) - return null; - - var index = charsetPart.IndexOf('='); - return charsetPart.Substring(index + 1); - } - - public static string GetMediaType(this HttpRequest request) { - var contentType = request.ContentType; - if (String.IsNullOrWhiteSpace(contentType)) - return null; - - int sepIndex = -1; - if ((sepIndex = contentType.IndexOf(';')) != -1) { - contentType = contentType.Substring(0, sepIndex); - } - - return contentType; - } - - public static Task ReadAsStringAsync(this HttpRequest request) { - var encoding = Encoding.UTF8; - var charset = request.GetCharset(); - - if (!String.IsNullOrWhiteSpace(charset)) { - encoding = Encoding.GetEncoding(charset); - } - - using (var reader = new StreamReader(request.Body, encoding)) { - return reader.ReadToEndAsync(); - } - } - - public static async Task ReadAsObjectAsync(this HttpRequest request, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken = default) { - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await request.ReadAsJsonObjectAsync(cancellationToken); - - T result; - - try { - result = obj.ToObject(JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - - } - - public static async Task ReadAsJsonObjectAsync(this HttpRequest request, CancellationToken cancellationToken = default) { - var token = await request.ReadAsJsonAsync(cancellationToken); - - if (token.Type != JTokenType.Object) - throw new FormatException("The json request is invalid"); - - return (JObject)token; - } - - public static async Task ReadAsJsonAsync(this HttpRequest request, CancellationToken cancellationToken = default) { - if (request.Headers == null) - throw new FormatException("Missing content headers"); - - - var mediaType = request.GetMediaType(); - - if (String.IsNullOrWhiteSpace(mediaType)) - throw new FormatException("Content-type of the request is missing"); - - if (mediaType != "application/json" && - mediaType != "text/json") - throw new NotSupportedException("Only JSON webhooks supported at this moment"); - - var encoding = Encoding.UTF8; - var charset = request.GetCharset(); - - if (!String.IsNullOrWhiteSpace(charset)) { - encoding = Encoding.GetEncoding(charset); - } - - JToken token; - - using (var textReader = new StreamReader(request.Body, encoding)) { - using (var jsonReader = new JsonTextReader(textReader)) { - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - } - - return token; - } - - - public static Task GetWebhookAsync(this HttpRequest request, Action afterRead, CancellationToken cancellationToken = default) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), afterRead, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request, WebhookReceiveOptions options, CancellationToken cancellationToken = default) - where T : class - => request.GetWebhookAsync(options, null, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), cancellationToken); - - public static Task GetWebhookAsync(this HttpRequest request) - where T : class - => request.GetWebhookAsync(default(CancellationToken)); - - public static async Task GetWebhookAsync(this HttpRequest request, WebhookReceiveOptions options, Action afterRead, CancellationToken cancellationToken) - where T : class { - if (options != null && options.ValidateSignature) { - var content = await request.ReadAsStringAsync(); - - if (!request.IsSignatureValid(content, options)) - throw new ArgumentException("The signature of the webhook is invalid"); - - return await WebhookJsonParser.ParseAsync(content, options.JsonSerializerSettings, afterRead, cancellationToken); - } - - return await request.ReadAsObjectAsync(options?.JsonSerializerSettings, afterRead, cancellationToken); - } - - private static bool IsSignatureValid(this HttpRequest request, string content, WebhookReceiveOptions options) { - string signature; - string algorithm = null; - - switch (options.SignatureLocation) { - case WebhookSignatureLocation.Header: - if (!request.Headers.TryGetValue(options.SignatureHeaderName, out var headerValue)) - return false; - - signature = headerValue.SingleOrDefault(); - - if (!string.IsNullOrEmpty(signature)) { - if (signature.StartsWith("sha256=")) { - signature = signature.Substring("sha256=".Length); - algorithm = "sha256"; - } - } - - break; - case WebhookSignatureLocation.QueryString: - if (request.Query.Count == 0) - return false; - - signature = request.Query[options.SignatureQueryStringKey]; - algorithm = request.Query["sig_alg"]; - - break; - default: - // should never happen - throw new NotSupportedException(); - } - - if (string.IsNullOrWhiteSpace(signature)) - return false; - - return WebhookSignatureValidator.IsValid(algorithm, content, options.Secret, signature); - } - - } -} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs new file mode 100644 index 0000000..f16f610 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookHandler.cs @@ -0,0 +1,44 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Provides functions for handling webhooks of a specific type. + /// + /// The type of the webhook to be handled + /// + /// + /// The typical usage scenario of usage of services implementing this interface + /// is within ASP.NET receiver middlewares that are registered in the pipeline, + /// and resolve all compatible handlers to notify a webhook has been received by the + /// application. + /// + /// + /// It is recommended that the implementation of this interface performs a rapid + /// handling of the webhook, and then delegates the actual processing to a background + /// or external process, to avoid blocking the pipeline. + /// + /// + public interface IWebhookHandler where TWebhook : class { + /// + /// Handles the given webhook. + /// + /// The instance of the webhook to be handled + /// + /// + /// Returns a that completes when the webhook has been handled. + /// + Task HandleAsync(TWebhook webhook, CancellationToken cancellationToken = default); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs new file mode 100644 index 0000000..ae823aa --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookJsonParser.cs @@ -0,0 +1,36 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Provides the capabilities to parse a webhook from a JSON stream. + /// + /// The type of the webhook to be parsed + public interface IWebhookJsonParser { + /// + /// Parses a webhook from the given UTF-8 encoded stream. + /// + /// The UTF-8 stream that represents the binary + /// data of a JSON-formatted webhook + /// + /// + /// Returns a that completes when the webhook + /// stream is parsed and produces the instance of the webhook. + /// + /// + /// Thrown if any error occurs while parsing the webhook stream. + /// + Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs index 7819098..5268418 100644 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookReceiver.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,14 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Threading.Tasks; -using System.Threading; - using Microsoft.AspNetCore.Http; namespace Deveel.Webhooks { - public interface IWebhookReceiver where T : class { - Task ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); + /// + /// A service that receives a webhook from a remote source. + /// + /// The type of the webhook that is received + public interface IWebhookReceiver where TWebhook : class { + /// + /// Receives a webhook from a remote source, posted through a + /// HTTP request given. + /// + /// The HTTP request that transports the webhook to be received + /// + /// + /// + /// Implementations of this contract should read the content of the request and + /// parsing it into a webhook instance of the type . + /// + /// + /// Optionally the implementation may also validate the signature of the request, + /// to ensure that the webhook is coming from a trusted source: this is not mandatory + /// but highly recommended. Verification of the signatures of webhook payloads might + /// affect performances, since the typical implementation of signers use strong hashing + /// algorithms. + /// + /// + /// + /// Returns a that completes when the webhook is received + /// + Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken); } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs new file mode 100644 index 0000000..c14f4f6 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookRequestVerifier.cs @@ -0,0 +1,71 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.AspNetCore.Http; + +namespace Deveel.Webhooks { + /// + /// A service that is used to verify a request of acknowledgement + /// by the sender of a webhook, before the webhook is sent. + /// + /// + /// The type of webhook that is being verified + /// + /// + /// + /// In several case scenarios, providers of webhooks require a verification + /// of the party to ensure they are the ones who should be receiving the + /// webhooks, and not a malicious party. + /// + /// + /// The process of verification is usually a two-step process, where the + /// request is first validated, and then the instance of the result of the + /// validation is passed back to the verifier to handle the result towards + /// the sender: this mechanism is used to ensure that the verification process + /// is specific for the provider of the webhook, since the different methodologies + /// implemented by various providers. + /// + /// + public interface IWebhookRequestVerifier { + /// + /// Verifies the request of acknowledgement of a webhook. + /// + /// + /// The HTTP request that is carrying the information + /// to acknowledge the webhook. + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that indicates the result + /// of the verification operation. + /// + Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default); + + + /// + /// Handles the result of the verification of a webhook request. + /// + /// The result of the verification that should be handled + /// The HTTP response used to notify the sender + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the result is handled. + /// + Task HandleResultAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs new file mode 100644 index 0000000..c3d484d --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner.cs @@ -0,0 +1,43 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Deveel.Webhooks { + /// + /// Provides functions for the signing of a webhook payload + /// + public interface IWebhookSigner { + /// + /// Gets the list of algorithms supported by this signer + /// + string[] Algorithms { get; } + + /// + /// Signs the given JSON body using the provided secret + /// as a key for the signature + /// + /// The JSON-formatted body of a webhook to sign + /// The secret used as a key for the signature + /// + /// A typical implementation of this method would return a string that + /// contains the signature, prefixed by the algorithm used to sign the + /// webhook in the format [algorithm]=[signature]. + /// + /// + /// Returns a string representing the signature of the given body + /// + string SignWebhook(string jsonBody, string secret); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs new file mode 100644 index 0000000..52ace28 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSignerProvider.cs @@ -0,0 +1,33 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Implements a provider of instances + /// for given algorithms. + /// + /// The type of webhook to provide signers for + public interface IWebhookSignerProvider { + /// + /// Gets the signer for the given algorithm. + /// + /// The name of the algorithm handled by + /// the signer to lookup for + /// + /// Returns an instance of that supports + /// the given algorithm, or null if no such signer is available. + /// + IWebhookSigner? GetSigner(string algorithm); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs new file mode 100644 index 0000000..3b0058a --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookSigner_1.cs @@ -0,0 +1,38 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Deveel.Webhooks { + /// + /// Defines a contract for a service that can verify the signature of a + /// specific type of webhook. + /// + /// The type of webhook to sign + /// + /// + /// Webhook signers are typically implementing the same behavior, + /// and this contract is a way to define a constraint usage of the signer + /// within a receiver context. + /// + /// + /// In some advanced scenarios, it is possible to have multiple signers + /// for the same algorithm but specific for a given type of webhook, according + /// to the different needs of the provider. + /// + /// + /// + public interface IWebhookSigner : IWebhookSigner where TWebhook : class { + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs new file mode 100644 index 0000000..a6426b8 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/IWebhookVerificationResult.cs @@ -0,0 +1,29 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Represents the result of a verification of a webhook request. + /// + /// + /// This contract is used by implementations of + /// to proceed with a two-step verification of a webhook request. + /// + public interface IWebhookVerificationResult { + /// + /// Gets whether the request is verified or not. + /// + public bool IsVerified { get; } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs new file mode 100644 index 0000000..b9486e7 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/LoggerExtensions.cs @@ -0,0 +1,61 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +using Microsoft.Extensions.Logging; + +namespace Deveel.Webhooks { + static partial class LoggerExtensions { + [LoggerMessage(EventId = 10001, Level = LogLevel.Debug, + Message = "A Webhook has arrived")] + public static partial void TraceWebhookArrived(this ILogger logger); + + [LoggerMessage(EventId = 10002, Level = LogLevel.Debug, + Message = "A Webhook has been received")] + public static partial void TraceWebhookReceived(this ILogger logger); + + [LoggerMessage(EventId = 10003, Level = LogLevel.Debug, + Message = "The webhook of has been handled by '{HandlerType}'")] + public static partial void TraceWebhookHandled(this ILogger logger, Type handlerType); + + [LoggerMessage(EventId = 10004, Level = LogLevel.Debug, + Message = "A request of verification has arrived")] + public static partial void TraceVerificationRequest(this ILogger logger); + + [LoggerMessage(EventId = 10005, Level = LogLevel.Debug, + Message = "The verification request has been completed successfully")] + public static partial void TraceSuccessVerification(this ILogger logger); + + [LoggerMessage(EventId = -20226, Level = LogLevel.Warning, + Message = "The verification request has failed")] + public static partial void WarnVerificationFailed(this ILogger logger); + + [LoggerMessage(EventId = -20222, Level = LogLevel.Warning, + Message = "It was not possible to resolve any webhook receiver")] + public static partial void WarnReceiverNotRegistered(this ILogger logger); + + [LoggerMessage(EventId = -20225, Level = LogLevel.Warning, + Message = "It was not possible to verify the signature of the webhook")] + public static partial void WarnInvalidSignature(this ILogger logger); + + [LoggerMessage(EventId = -20224, Level = LogLevel.Warning, + Message = "The received webhook is invalid")] + public static partial void WarnInvalidWebhook(this ILogger logger); + + [LoggerMessage(EventId = -20223, Level = LogLevel.Error, + Message = "It was not possible to receive a webhook for an unhandled error")] + public static partial void LogUnhandledReceiveError(this ILogger logger, Exception error); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs new file mode 100644 index 0000000..8366779 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/OptionsSnapshotExtensions.cs @@ -0,0 +1,50 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + /// + /// Extends the interface + /// to provide standard methods to retrieve the options for a specific + /// webhook receiver. + /// + public static class OptionsSnapshotExtensions { + /// + /// Gets the options for the webhook receiver of the given type. + /// + /// The type of webhook handled by the receiver + /// The instance of the to extend + /// + /// Returns the options for the receiver of the given type. + /// + public static WebhookReceiverOptions GetReceiverOptions(this IOptionsSnapshot options) + => options.Get(typeof(TWebhook).Name); + + /// + /// Gets the options for the webhook verifier of the given type. + /// + /// + /// The type of webhook handled by the verifier + /// + /// + /// The instance of the to extend + /// + /// + /// Returns the options for the verifier of the given type. + /// + public static WebhookVerificationOptions GetVerificationOptions(this IOptionsSnapshot options) + => options.Get(typeof(TWebhook).Name); + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs new file mode 100644 index 0000000..8363813 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/Sha256WebhookSigner.cs @@ -0,0 +1,71 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Security.Cryptography; +using System.Text; + +namespace Deveel.Webhooks { + /// + /// A default implementation of the that handles + /// a typical webhook signature using the SHA-256 algorithm. + /// + public class Sha256WebhookSigner : IWebhookSigner { + /// + public virtual string[] Algorithms => new[] { "sha256", "sha-256" }; + + /// + /// Computes the hash of the given using the + /// provided secret, + /// + /// The JSON-formatted string that represents the webhook to sign + /// A secret used as key for the signature + /// + /// Returns a byte array representing the hash of the given body + /// + /// + /// Thrown when the is null or empty + /// + protected virtual byte[] ComputeHash(string jsonBody, string secret) { + if (string.IsNullOrWhiteSpace(secret)) + throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); + + var key = Encoding.UTF8.GetBytes(secret); + using var sha256 = new HMACSHA256(key); + + return sha256.ComputeHash(Encoding.UTF8.GetBytes(jsonBody)); + } + + /// + /// Gets the string representation of the signature, given the hash + /// + /// The byte hash of the signature + /// + /// Returns a string representing the signature of the given body + /// + protected virtual string GetSignatureString(byte[] hash) { + return $"{Algorithms[0]}={Convert.ToBase64String(hash)}"; + } + + /// + public virtual string SignWebhook(string jsonBody, string secret) { + if (string.IsNullOrWhiteSpace(jsonBody)) + throw new ArgumentException($"'{nameof(jsonBody)}' cannot be null or whitespace.", nameof(jsonBody)); + if (string.IsNullOrWhiteSpace(secret)) + throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); + + var hash = ComputeHash(jsonBody, secret); + return GetSignatureString(hash); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs new file mode 100644 index 0000000..9c729a8 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/SystemTextWebhookJsonParser.cs @@ -0,0 +1,52 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace Deveel.Webhooks { + /// + /// Provides a default implementation of the + /// that is using the System.Text.Json library for parsing the JSON + /// representations of webhooks. + /// + /// The type of the webhook to parse + public sealed class SystemTextWebhookJsonParser : IWebhookJsonParser where TWebhook : class { + /// + /// Initializes a new instance of the + /// + /// A set of options to control the behavior of the serialization + /// + /// When the is not provided, a new instance of the + /// is created with the + /// default configurations. + /// + public SystemTextWebhookJsonParser(JsonSerializerOptions? options = null) { + JsonSerializerOptions = options ?? new JsonSerializerOptions(); + } + + /// + /// Gets the options used to control the behavior of the serialization + /// + public JsonSerializerOptions JsonSerializerOptions { get; } + + /// + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + try { + return await JsonSerializer.DeserializeAsync(utf8Stream, JsonSerializerOptions, cancellationToken); + } catch (Exception ex) { + throw new WebhookParseException("Could not parse the stream to a webhook", ex); + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs new file mode 100644 index 0000000..57b26f6 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookDelegatedReceiverMiddleware.cs @@ -0,0 +1,115 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + class WebhookDelegatedReceiverMiddleware where TWebhook : class { + private readonly RequestDelegate next; + private readonly Func? asyncHandler; + private readonly Action? syncHandler; + + public WebhookDelegatedReceiverMiddleware(RequestDelegate next, + Func handler) { + this.next = next; + asyncHandler = handler; + } + + public WebhookDelegatedReceiverMiddleware(RequestDelegate next, + Action handler) { + this.next = next; + syncHandler = handler; + } + + private WebhookReceiverOptions GetOptions(HttpContext context) { + var snapshot = context?.RequestServices?.GetService>(); + return snapshot?.GetReceiverOptions() ?? new WebhookReceiverOptions(); + } + + private ILogger GetLogger(HttpContext context) { + var loggerFactory = context?.RequestServices?.GetService(); + return loggerFactory?.CreateLogger>() ?? + NullLogger>.Instance; + } + + + public async Task InvokeAsync(HttpContext context) { + var options = GetOptions(context); + var logger = GetLogger(context); + + try { + logger.TraceWebhookArrived(); + + var receiver = context.RequestServices.GetService>(); + + WebhookReceiveResult? result = null; + + if (receiver != null) { + result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); + + if (result?.Successful ?? false) { + var webhook = result?.Webhook; + + if (webhook == null) { + logger.WarnInvalidWebhook(); + } else { + logger.TraceWebhookReceived(); + + if (asyncHandler != null) { + await asyncHandler(context, webhook, context.RequestAborted); + + logger.TraceWebhookHandled(typeof(Func)); + } else if (syncHandler != null) { + syncHandler(context, webhook); + + logger.TraceWebhookHandled(typeof(Action)); + } + } + } else { + logger.WarnInvalidWebhook(); + } + } else if (result?.SignatureFailed ?? false) { + logger.WarnInvalidSignature(); + } else { + logger.WarnReceiverNotRegistered(); + } + + await next.Invoke(context); + + if (!context.Response.HasStarted && result != null) { + if ((result?.SignatureValidated ?? false) && !(result?.SignatureValid ?? false)) { + context.Response.StatusCode = options?.InvalidStatusCode ?? 400; + } else if ((result?.Successful ?? false)) { + context.Response.StatusCode = options?.ResponseStatusCode ?? 204; + } + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + } catch (WebhookReceiverException ex) { + logger.LogUnhandledReceiveError(ex); + + if (!context.Response.HasStarted) { + context.Response.StatusCode = options?.ErrorStatusCode ?? 500; + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + } + } + } +} \ No newline at end of file diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs new file mode 100644 index 0000000..deaba08 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookJsonParserExtensions.cs @@ -0,0 +1,45 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; + +namespace Deveel.Webhooks { + /// + /// Extends the with + /// methods for the parsing of a webhooks. + /// + public static class WebhookJsonParserExtensions { + /// + /// Parses a webhook from the given string. + /// + /// The type of the webhook to be parsed + /// The instance of the to extend + /// The UTF-8 encoded JSON-formatted string to be parsed + /// + /// + /// Returns a that resolves to the parsed webhook + /// + /// + /// Thrown if any error occurs while parsing the webhook + /// + public static async Task ParseWebhookAsync(this IWebhookJsonParser parser, string? json, CancellationToken cancellationToken = default) + where TWebhook : class { + if (string.IsNullOrWhiteSpace(json)) + return null; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return await parser.ParseWebhookAsync(stream, cancellationToken); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs similarity index 55% rename from src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs rename to src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs index ab826a3..c5f8845 100644 --- a/src/Deveel.Webhooks.Receiver/Webhooks/IHttpWebhookReceiver.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookParseException.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -13,12 +13,22 @@ // limitations under the License. using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; namespace Deveel.Webhooks { - public interface IHttpWebhookReceiver where T : class { - Task ReceiveAsync(HttpRequestMessage request, CancellationToken cancellationToken); + /// + /// An exception thrown when a webhook cannot be parsed. + /// + public sealed class WebhookParseException : WebhookReceiverException { + /// + public WebhookParseException() { + } + + /// + public WebhookParseException(string message) : base(message) { + } + + /// + public WebhookParseException(string message, Exception innerException) : base(message, innerException) { + } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs new file mode 100644 index 0000000..e7fd35d --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiveResult.cs @@ -0,0 +1,80 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Describes the result of a webhook receive operation. + /// + /// + /// The type of webhook that was received + /// + /// + /// When this object is returned from a webhook receiver, it can be used + /// to determine if the webhook was successfully received and if the signature was valid. + /// + public readonly struct WebhookReceiveResult where TWebhook : class { + /// + /// Constructs a new result of a webhook receive operation. + /// + /// The webhook instance that was received, or null + /// if it was not possible to receive the webhook for any reason (invalid content, + /// missing or invalid signature, etc.) + /// + /// Whether the signature of the webhook was valid, or null if the signature + /// was not checked. + /// + public WebhookReceiveResult(TWebhook? webhook, bool? signatureValid) : this() { + Webhook = webhook; + SignatureValid = signatureValid; + } + + /// + /// Gets the webhook instance that was received, or null if it was not + /// possible to receive the webhook for any reason (invalid content, missing or + /// invalid signature, etc.). + /// + public TWebhook? Webhook { get; } + + /// + /// Gets whether the signature of the webhook was valid, or null if the + /// signature was not checked. + /// + public bool? SignatureValid { get; } + + /// + /// Implicitly converts a to a + /// successful result with the given webhook instance. + /// + /// + /// The webhook instance that was received + /// + public static implicit operator WebhookReceiveResult(TWebhook? webhook) + => new WebhookReceiveResult(webhook, null); + + /// + /// Gets whether the signature of the webhook was validated. + /// + public bool SignatureValidated => SignatureValid.HasValue; + + /// + /// Gets whether the webhook was successfully received. + /// + public bool Successful => Webhook != null && (!SignatureValidated || SignatureValid == true); + + /// + /// Gets whether the webhook was received but the signature was invalid. + /// + public bool SignatureFailed => SignatureValidated && SignatureValid == false; + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs new file mode 100644 index 0000000..afb2215 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiver.cs @@ -0,0 +1,337 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + /// + /// A default implementation of the + /// that uses the registered options and services to receive a webhook. + /// + /// The type of the webhook to receive + /// + /// + /// This class implements a default behavior for the , + /// that is based on common patterns for the processing of webhooks. + /// + /// + /// It is recommended to inherit from this class to implement a custom receiver behavior, + /// when the default behavior is not sufficient. In some case scenarios, it is recommended + /// to discard the possibility of using this class and implement the . + /// + /// + public class WebhookReceiver : IWebhookReceiver + where TWebhook : class { + private readonly IWebhookSignerProvider? signerProvider; + + /// + /// Constructs a instance. + /// + /// An instance of the that is + /// used to resolve the configurations specific for this receiver. + /// A provider of services that + /// are used to verify the signature of webhooks + /// A parser that is used to process the JSON + /// content of requests and obtain instances of webhooks. By default, if this + /// value is null a new instance of + /// is created using the default options. + public WebhookReceiver(IOptionsSnapshot options, + IWebhookJsonParser? jsonParser = null, + IWebhookSignerProvider? signerProvider = null) + : this(options.GetReceiverOptions(), jsonParser) { + this.signerProvider = signerProvider; + } + + /// + /// Constructs a instance. + /// + /// The configurations used by the receiver to + /// process the requests + /// A parser that is used to process the JSON + /// content of requests and obtain instances of webhooks. By default, if this + /// value is null a new instance of + /// is created using the default options. + /// + /// Thrown if the given is null + /// + protected WebhookReceiver(WebhookReceiverOptions options, IWebhookJsonParser? jsonParser) { + ReceiverOptions = options ?? throw new ArgumentNullException(nameof(options)); + JsonParser = jsonParser ?? new SystemTextWebhookJsonParser(); + } + + /// + /// Gets the options used by the receiver to process the requests. + /// + protected virtual WebhookReceiverOptions ReceiverOptions { get; } + + /// + /// Gets the parser used to process the JSON content of requests + /// + protected virtual IWebhookJsonParser JsonParser { get; } + + /// + /// Resolves a webhook signer for the given algorithm. + /// + /// The hashing algorithm used to sign the webhook + /// + /// Returns an instance of that is used to + /// sign the webhook, or null if no signer is available for the + /// given algorithm. + /// + protected virtual IWebhookSigner? GetSigner(string algorithm) { + return signerProvider?.GetSigner(algorithm); + } + + /// + /// Signs the JSON body of a webhook using the given algorithm and secret. + /// + /// The JSON-formatted representation of a webhook + /// The hashing algorithm used to sign the webhook + /// A secret word used to compute the signature + /// + /// Returns a string that is the signature of the given JSON body, or null + /// if no signer is available for the given algorithm. + /// + protected virtual string? SignWebhook(string jsonBody, string algorithm, string secret) { + return GetSigner(algorithm)?.SignWebhook(jsonBody, secret); + } + + /// + /// Parses the JSON body of a webhook request. + /// + /// The JSON-formatted body of the webhook to be parsed + /// + /// + /// Returns an instance of that completes the + /// parsing operation to obtain the webhook. + /// + /// + /// Thrown if the parsing operation is not supported by the receiver. + /// + protected virtual async Task ParseJsonAsync(string? jsonBody, CancellationToken cancellationToken) { + if (JsonParser == null) + throw new NotSupportedException("The JSON parser was not provided"); + + return await JsonParser.ParseWebhookAsync(jsonBody, cancellationToken); + } + + /// + /// Parses the JSON body of a webhook request. + /// + /// A stream that is UTF-8 encoded and that provides the + /// body of the webhook to be parsed + /// + /// + /// Returns an instance of that completes the + /// parsing operation to obtain the webhook. + /// + /// + /// Thrown if the parsing operation is not supported by the receiver. + /// + protected virtual async Task ParseJsonAsync(Stream utf8Stream, CancellationToken cancellationToken) { + if (JsonParser == null) + throw new NotSupportedException("The JSON parser was not provided"); + + return await JsonParser.ParseWebhookAsync(utf8Stream, cancellationToken); + } + + private string? GetAlgorithm(string signature) { + var index = signature.IndexOf('='); + if (index == -1) + return ReceiverOptions?.Signature?.Algorithm; + + return signature.Substring(0, index); + } + + private bool ValidateSignature() + => (ReceiverOptions.VerifySignature ?? false) && + ReceiverOptions.Signature != null && + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.ParameterName) && + !String.IsNullOrWhiteSpace(ReceiverOptions.Signature.Secret); + + /// + /// Attempts to get the signature from the given request. + /// + /// The HTTP request from the sender of the webhook + /// that should include a signature + /// The signature of the webhook discovered from within + /// the request + /// + /// By default this method verifies if the configuration of the receiver + /// explicitly requires or forbids the verification of signatures: in the + /// cases the receiver is configured not to verify signatures, this method + /// will return false even if the signature is present in the request. + /// + /// + /// Returns true if the signature was found in the request, or false otherwise. + /// + protected virtual bool TryGetSignature(HttpRequest request, [MaybeNullWhen(false)] out string? signature) { + if (!ValidateSignature()) { + signature = null; + return false; + } + + if (ReceiverOptions.Signature.Location == WebhookSignatureLocation.Header) { + if (!request.Headers.TryGetValue(ReceiverOptions.Signature.ParameterName, out var header)) { + signature = null; + return false; + } + + signature = header.ToString(); + return true; + } else if (ReceiverOptions.Signature.Location == WebhookSignatureLocation.QueryString) { + if (!request.Query.TryGetValue(ReceiverOptions.Signature.ParameterName, out var value)) { + signature = null; + return false; + } + + signature = value.ToString(); + return true; + } + + signature = null; + return false; + } + + /// + /// Verifies if the given signature sent alongside a webhook is + /// valid for the given JSON body of the webhook itself. + /// + /// The signature sent alongside the webhook + /// The signing hash algorithm used to compute the signature + /// The JSON-formatted body of the webhook + /// + /// + /// The default behavior of this method is to return true if the verification + /// of the signature is disabled in the configuration of the receiver. + /// + /// + /// To verify the signature, this method will use the secret word configured as a key + /// to compute the signature of the given JSON body, and then compare it with the one + /// sent alongside the webhook. + /// + /// + /// + /// Returns true if the signature is valid for the given webhook, + /// or false otherwise. + /// + protected virtual bool IsSignatureValid(string signature, string algorithm, string jsonBody) { + if (!ValidateSignature()) + return true; + + if (String.IsNullOrWhiteSpace(ReceiverOptions?.Signature?.Secret)) + return false; + + var computedSignature = SignWebhook(jsonBody, algorithm, ReceiverOptions.Signature.Secret); + if (String.IsNullOrWhiteSpace(computedSignature)) + return false; + + return String.Equals(computedSignature, signature, StringComparison.Ordinal); + } + + /// + /// Attempts to validate the webhook request. + /// + /// The HTTP request used to post the webhook + /// + /// Returns a that describes the result of the validation. + /// + protected async Task TryValidateWebhook(HttpRequest request) { + using var reader = new StreamReader(request.Body, Encoding.UTF8); + var jsonBody = await reader.ReadToEndAsync(); + + if (!ValidateSignature() || + !TryGetSignature(request, out var signature) || + String.IsNullOrWhiteSpace(signature)) + return new ValidateResult(jsonBody, false, null); + + var algorithm = GetAlgorithm(signature); + if (String.IsNullOrWhiteSpace(algorithm)) + return new ValidateResult(jsonBody, true, false); + + var isValid = IsSignatureValid(signature, algorithm, jsonBody); + + return new ValidateResult(jsonBody, true, isValid); + } + + /// + public virtual async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { + if (String.IsNullOrWhiteSpace(request.ContentType) || + !request.ContentType.StartsWith("application/json")) + return new WebhookReceiveResult(null, null); + + try { + if (ValidateSignature()) { + var result = await TryValidateWebhook(request); + + if (result.SignatureValidated && !(result.IsValid ?? false)) { + return new WebhookReceiveResult(null, false); + } else if ((result.SignatureValidated && (result.IsValid ?? false)) || + !result.SignatureValidated) { + var signatureValid = result.SignatureValidated && (result.IsValid ?? false); + var webhook = await ParseJsonAsync(result.JsonBody, cancellationToken); + return new WebhookReceiveResult(webhook, signatureValid); + } else { + throw new NotSupportedException(); + } + } else { + return await ParseJsonAsync(request.Body, cancellationToken); + } + } catch (WebhookReceiverException) { + throw; + } catch(Exception ex) { + throw new WebhookReceiverException("Could not receive the webhook", ex); + } + } + + /// + /// Describes the result of a validation attempt. + /// + protected readonly struct ValidateResult { + /// + /// Indicates if the signature was actually validated. + /// + public bool SignatureValidated { get; } + + /// + /// Indicates if the signature was valid, or null if the + /// signature was not validated. + /// + public bool? IsValid { get; } + + /// + /// Gets the JSON body of the webhook, or null if it was + /// not possible to read it from the request. + /// + public string? JsonBody { get; } + + /// + /// Initializes a new instance of the struct. + /// + /// The JSON-formatted string that represents the webhook + /// Indicates if the webhook signature was actually validated + /// Indicates if the webhook signature was valid + public ValidateResult(string? jsonBody, bool validated, bool? isValid) : this() { + JsonBody = jsonBody; + SignatureValidated = validated; + IsValid = isValid; + } + } + + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs new file mode 100644 index 0000000..fc8b9b2 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilder.cs @@ -0,0 +1,463 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text; +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Deveel.Webhooks { + /// + /// An object that can be used to configure a receiver of webhooks + /// + /// The type of webhooks to receive + /// + /// When constructing the builder a set of default services are registered, + /// such as the middleware for the receiver and the verifier, a default JSON + /// parser and the default receiver service. + /// + public sealed class WebhookReceiverBuilder where TWebhook : class { + /// + /// Initializes a new instance of the class + /// + /// + /// The service collection to which the receiver is added + /// + /// + /// Thrown if the type is not a non-abstract class + /// + /// + /// Thrown if the argument is null + /// + public WebhookReceiverBuilder(IServiceCollection services) { + if (!typeof(TWebhook).IsClass || typeof(TWebhook).IsAbstract) + throw new ArgumentException("The webhook type must be a non-abstract class"); + + Services = services ?? throw new ArgumentNullException(nameof(services)); + + Services.TryAddSingleton(this); + + RegisterReceiverMiddleware(); + RegisterDefaultReceiver(); + + UseJsonParser(); + } + + /// + /// Constructs a new instance of the class + /// instantiating a new service collection + /// + public WebhookReceiverBuilder() + : this(new ServiceCollection()) { + } + + /// + /// Gets the service collection to which the receiver is added + /// + public IServiceCollection Services { get; } + + private void RegisterReceiverMiddleware() { + Services.TryAddScoped>(); + } + + private void RegisterVerifierMiddleware() { + Services.TryAddScoped>(); + } + + private void RegisterDefaultReceiver() { + Services.TryAddScoped, WebhookReceiver>(); + Services.TryAddScoped>(); + } + + /// + /// Registers an implementation of the + /// that is used to receive the webhooks + /// + /// + /// The type of the receiver to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the receiver registered + /// + public WebhookReceiverBuilder UseReceiver() + where TReceiver : class, IWebhookReceiver { + + Services.AddScoped, TReceiver>(); + + if (!typeof(TReceiver).IsAbstract) + Services.AddScoped(typeof(TReceiver), typeof(TReceiver)); + + return this; + } + + /// + /// Registers an implementation of the + /// that is used to verify the webhooks verification requests from + /// senders + /// + /// + /// The type of the verifier to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the verifier registered + /// + public WebhookReceiverBuilder UseVerifier() + where TVerifier : class, IWebhookRequestVerifier { + RegisterVerifierMiddleware(); + + Services.AddScoped, TVerifier>(); + + if (!typeof(TVerifier).IsAbstract) + Services.AddScoped(typeof(TVerifier), typeof(TVerifier)); + + return this; + } + + /// + /// Registers the default implementation of the + /// + /// + /// A delegate that can be used to configure the options for the verifier + /// + /// + /// Returns the current builder instance with the verifier registered + /// + /// + public WebhookReceiverBuilder UseVerifier(Action configure) { + UseVerifier>(); + + Services.AddOptions(typeof(TWebhook).Name) + .Configure(configure); + + return this; + } + + /// + /// Registers the default implementation of the + /// + /// + /// The path to the section in the configuration that contains the options + /// + /// + /// Return the current builder instance with the verifier registered + /// + public WebhookReceiverBuilder UserVerifier(string sectionPath) { + UseVerifier>(); + + Services.AddOptions(typeof(TWebhook).Name) + .BindConfiguration(sectionPath); + + return this; + } + + /// + /// Registers an handler for the webhooks of type + /// that were received. + /// + /// + /// The type of the handler to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the handler registered + /// + public WebhookReceiverBuilder AddHandler() + where THandler : class, IWebhookHandler { + + Services.AddScoped, THandler>(); + + if (typeof(THandler).IsClass && !typeof(THandler).IsAbstract) + Services.AddScoped(typeof(THandler), typeof(THandler)); + + return this; + } + + /// + /// Configures the receiver with the options from the given section path + /// within the configuration of the application + /// + /// + /// The path to the section within the configuration of the application + /// where the options are defined + /// + /// + /// Returns the current builder instance with the options configured + /// + public WebhookReceiverBuilder Configure(string sectionPath) { + // TODO: Validate the configured options + Services.AddOptions(typeof(TWebhook).Name) + .BindConfiguration(sectionPath); + + return this; + } + + /// + /// Configures the receiver with the given options + /// + /// + /// A delegate that is used to configure the options of the receiver + /// + /// + /// Returns the current builder instance with the options configured + /// + public WebhookReceiverBuilder Configure(Action configure) { + // TODO: Validate the configured options + Services.AddOptions(typeof(TWebhook).Name) + .Configure(configure); + + return this; + } + + /// + /// Registers a parser that is used to parse the JSON body of webhooks received + /// + /// + /// The type of the parser to use for the webhooks of type + /// + /// + /// A value that specifies the lifetime of the parser service (defaults to ) + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(ServiceLifetime lifetime = ServiceLifetime.Singleton) + where TParser : class, IWebhookJsonParser { + + Services.Add(new ServiceDescriptor(typeof(IWebhookJsonParser), typeof(TParser), lifetime)); + + if (typeof(TParser).IsClass && !typeof(TParser).IsAbstract) + Services.Add(new ServiceDescriptor(typeof(TParser), typeof(TParser), lifetime)); + + return this; + } + + /// + /// Registers a parser that is used to parse the JSON body of webhooks received + /// + /// + /// The type of the parser to use for the webhooks of type + /// + /// + /// An instance of the parser to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(TParser parser) + where TParser : class, IWebhookJsonParser { + Services.AddSingleton(typeof(IWebhookJsonParser), parser); + Services.AddSingleton(parser); + + return this; + } + + /// + /// Registers a default parser that is used to parse the JSON body of webhooks received + /// + /// + /// An optional set of options that are used to configure the JSON parser behavior + /// + /// + /// Returns the current builder instance with the parser registered + /// + public WebhookReceiverBuilder UseJsonParser(JsonSerializerOptions? options = null) { + Services.TryAddSingleton>(_ => new SystemTextWebhookJsonParser(options)); + Services.TryAddSingleton(_ => new SystemTextWebhookJsonParser(options)); + + return this; + } + + /// + /// Registers a function as parser that is used to parse the JSON body of webhooks received + /// + /// + /// The function that is used to parse the JSON body of webhooks received + /// + /// + /// Returns the current builder instance with the parser registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseJsonParser(Func> parser) { + if (parser is null) + throw new ArgumentNullException(nameof(parser)); + + Services.TryAddSingleton>(_ => new DelegatedJsonParser(parser)); + + return this; + } + + /// + /// Registers a function as parser that is used to parse the JSON body of webhooks received + /// + /// + /// The function that is used to parse the JSON body of webhooks received + /// + /// + /// Returns the current builder instance with the parser registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseJsonParser(Func parser) { + if (parser is null) + throw new ArgumentNullException(nameof(parser)); + + Services.TryAddSingleton>(_ => new DelegatedJsonParser(parser)); + + return this; + } + + /// + /// Registers a service that is used to sign the payload of webhooks received + /// + /// + /// The type of the signer to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the signer registered + /// + public WebhookReceiverBuilder UseSigner() where TSigner : class, IWebhookSigner { + if (!typeof(IWebhookSigner).IsAssignableFrom(typeof(TSigner))) { + Services.AddSingleton>(provider => { + var signer = provider.GetRequiredService(); + return new WebhookSignerWrapper(signer); + }); + } else { + Services.AddSingleton(provider => (IWebhookSigner) provider.GetRequiredService()); + } + + Services.TryAddSingleton(); + Services.TryAddSingleton, DefaultWebhookSignerProvider>(); + + return this; + } + + /// + /// Registers the default implementation of that is used + /// to sign the payload of webhooks received with a SHA256 hash + /// + /// + /// Returns the current builder instance with the signer registered + /// + public WebhookReceiverBuilder UseSha256Signer() + => UseSigner(); + + /// + /// Registers a service that is used to sign the payload of webhooks received + /// + /// + /// The type of the signer to use for the webhooks of type + /// + /// + /// The instance of the signer to use for the webhooks of type + /// + /// + /// Returns the current builder instance with the signer registered + /// + /// + /// Thrown when the given is null + /// + public WebhookReceiverBuilder UseSigner(TSigner signer) + where TSigner : class, IWebhookSigner { + if (signer is null) + throw new ArgumentNullException(nameof(signer)); + + if (!typeof(IWebhookSigner).IsAssignableFrom(typeof(TSigner))) { + Services.AddSingleton>(_ => new WebhookSignerWrapper(signer)); + } else { + Services.AddSingleton(provider => (IWebhookSigner) provider.GetRequiredService()); + } + + Services.TryAddSingleton(signer); + Services.TryAddSingleton, DefaultWebhookSignerProvider>(); + + return this; + } + + #region DefaultWebhookSignerProvider + + class DefaultWebhookSignerProvider : IWebhookSignerProvider { + private readonly IDictionary signers; + + public DefaultWebhookSignerProvider(IEnumerable> signers) { + this.signers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (signers != null) { + foreach (var signer in signers) { + foreach (var alg in signer.Algorithms) { + this.signers[alg] = signer; + } + } + } + } + + public IWebhookSigner? GetSigner(string algorithm) { + if (!signers.TryGetValue(algorithm, out var signer)) + return null; + + return signer; + } + } + + #endregion + + #region WebhookSignatureProviderWrapper + + class WebhookSignerWrapper : IWebhookSigner { + private readonly IWebhookSigner signer; + + public WebhookSignerWrapper(IWebhookSigner signer) { + this.signer = signer; + } + + public string[] Algorithms => signer.Algorithms; + + public string SignWebhook(string jsonBody, string secret) => signer.SignWebhook(jsonBody, secret); + } + + #endregion + + #region DelegatedJsonParser + + class DelegatedJsonParser : IWebhookJsonParser { + private readonly Func>? streamParser; + private readonly Func? syncStringParser; + + public DelegatedJsonParser(Func syncStringParser) { + this.syncStringParser = syncStringParser; + } + + public DelegatedJsonParser(Func> parser) { + this.streamParser = parser; + } + + public async Task ParseWebhookAsync(Stream utf8Stream, CancellationToken cancellationToken = default) { + if (streamParser != null) { + return await streamParser(utf8Stream, cancellationToken); + } else if (syncStringParser != null) { + using var reader = new StreamReader(utf8Stream, Encoding.UTF8); + var json = await reader.ReadToEndAsync(); + + return syncStringParser(json); + } + + return null; + } + } + + #endregion + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs deleted file mode 100644 index b82f7f0..0000000 --- a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverBuilderExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class WebhookReceiverBuilderExtensions { - public static IWebhookReceiverBuilder AddReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton) - where TReceiver : class, IWebhookReceiver - where TWebhook : class { - return builder.ConfigureServices(services => { - services.Add(new ServiceDescriptor(typeof(IWebhookReceiver), typeof(TReceiver), lifetime)); - services.Add(new ServiceDescriptor(typeof(TReceiver), typeof(TReceiver), lifetime)); - }); - } - - public static IWebhookReceiverBuilder AddReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Singleton) - where TWebhook : class - => builder.AddReceiver, TWebhook>(lifetime); - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs similarity index 53% rename from src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs rename to src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs index c8e4246..a39b8c0 100644 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiveOptions.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverException.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,20 +14,24 @@ using System; -using Newtonsoft.Json; - namespace Deveel.Webhooks { - public class WebhookReceiveOptions { - public string Secret { get; set; } - - public bool ValidateSignature { get; set; } = false; - - public string SignatureHeaderName { get; set; } = "X-WEBHOOK-SIGNATURE"; - - public string SignatureQueryStringKey { get; set; } = "webhook-signature"; - - public WebhookSignatureLocation SignatureLocation { get; set; } = WebhookSignatureLocation.QueryString; - - public JsonSerializerSettings JsonSerializerSettings { get; set; } = new JsonSerializerSettings(); + /// + /// An exception thrown when an error occurs during the processing of a webhook + /// + public class WebhookReceiverException : Exception { + /// + public WebhookReceiverException(string message, Exception innerException) + : base(message, innerException) { + } + + /// + public WebhookReceiverException(string message) + : base(message) { + } + + /// + public WebhookReceiverException() { + + } } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs new file mode 100644 index 0000000..2fb11aa --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverMiddleware.cs @@ -0,0 +1,93 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + class WebhookReceiverMiddleware : IMiddleware where TWebhook : class { + private readonly IEnumerable>? handlers; + private readonly IWebhookReceiver receiver; + private readonly WebhookReceiverOptions options; + private readonly ILogger logger; + + public WebhookReceiverMiddleware( + IOptionsSnapshot options, + IWebhookReceiver receiver, + IEnumerable>? handlers = null, + ILogger>? logger = null) { + this.options = options.GetReceiverOptions(); + this.receiver = receiver; + this.handlers = handlers; + this.logger = logger ?? NullLogger>.Instance; + } + + private int SuccessStatusCode => options.ResponseStatusCode ?? 200; + + private int FailureStatusCode => options.ErrorStatusCode ?? 500; + + private int InvalidStatusCode => options.InvalidStatusCode ?? 400; + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + try { + logger.TraceWebhookArrived(); + + var result = await receiver.ReceiveAsync(context.Request, context.RequestAborted); + + if (result.Successful) { + logger.TraceWebhookReceived(); + } else if (result.SignatureFailed) { + logger.WarnInvalidSignature(); + } else if (!result.Successful) { + logger.WarnInvalidWebhook(); + } + + if (handlers != null && result.Successful && result.Webhook != null) { + foreach (var handler in handlers) { + await handler.HandleAsync(result.Webhook, context.RequestAborted); + + logger.TraceWebhookHandled(handler.GetType()); + } + } + + await next.Invoke(context); + + if (!context.Response.HasStarted) { + if (result.Successful) { + context.Response.StatusCode = SuccessStatusCode; + } else if (result.SignatureFailed) { + context.Response.StatusCode = InvalidStatusCode; + } else { + context.Response.StatusCode = FailureStatusCode; + } + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + } catch (WebhookReceiverException ex) { + logger.LogUnhandledReceiveError(ex); + + if (!context.Response.HasStarted) { + context.Response.StatusCode = FailureStatusCode; + + // TODO: should we emit anything here? + await context.Response.WriteAsync(""); + } + } + + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs new file mode 100644 index 0000000..7ef4eb4 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookReceiverOptions.cs @@ -0,0 +1,51 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Deveel.Webhooks { + /// + /// Provides the configuration options for a webhook receiver. + /// + public class WebhookReceiverOptions { + /// + /// Gets or sets whether the signature of the incoming webhook + /// should be verified. + /// + public bool? VerifySignature { get; set; } + + /// + /// Gets or sets the options for the signature verification. + /// + public WebhookSignatureOptions? Signature { get; set; } = new WebhookSignatureOptions(); + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// processing is successful (201 by default). + /// + public int? ResponseStatusCode { get; set; } = 204; + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// processing failed for an internal error (500 by default). + /// + public int? ErrorStatusCode { get; set; } = 500; + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// from the sender is invalid (400 by default). + /// + public int? InvalidStatusCode { get; set; } = 400; + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs new file mode 100644 index 0000000..a30010a --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerfierMiddleware.cs @@ -0,0 +1,71 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + class WebhookRequestVerfierMiddleware : IMiddleware where TWebhook : class { + private readonly WebhookReceiverOptions options; + private readonly IWebhookRequestVerifier requestVerifier; + private readonly ILogger logger; + + public WebhookRequestVerfierMiddleware( + IOptionsSnapshot options, + IWebhookRequestVerifier requestVerifier, + ILogger>? logger = null) { + this.options = options.GetReceiverOptions(); + this.requestVerifier = requestVerifier; + this.logger = logger ?? NullLogger>.Instance; + } + + private int FailureStatusCode => options.ErrorStatusCode ?? 500; + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) { + try { + logger.TraceVerificationRequest(); + + var result = await requestVerifier.VerifyRequestAsync(context.Request, context.RequestAborted); + + if (result != null) { + if (result.IsVerified) { + logger.TraceSuccessVerification(); + } else { + logger.WarnVerificationFailed(); + } + } + + await next.Invoke(context); + + if (!context.Response.HasStarted && result != null) { + await requestVerifier.HandleResultAsync(result, context.Response, context.RequestAborted); + } + } catch (WebhookReceiverException ex) { + logger.LogUnhandledReceiveError(ex); + + if (!context.Response.HasStarted) { + context.Response.StatusCode = FailureStatusCode; + + // TODO: Should we emit anything here? + await context.Response.WriteAsync(""); + } + } + } + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs new file mode 100644 index 0000000..c11685b --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookRequestVerifier.cs @@ -0,0 +1,149 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks { + /// + /// A default implementation of a verifier of a webhook request that performs + /// a simple check for a token in the request matching one configured. + /// + /// + /// The type of webhook that is being verified + /// + public class WebhookRequestVerifier : IWebhookRequestVerifier + where TWebhook : class { + /// + /// Constructs a instance with a + /// selector that resolves the options for the given type of webhook. + /// + /// + /// The provider of the options for the verification of the webhook request + /// + public WebhookRequestVerifier(IOptionsSnapshot options) + : this(options.GetVerificationOptions()) { + } + + /// + /// Constructs a instance with the given options + /// + /// + /// + protected WebhookRequestVerifier(WebhookVerificationOptions options) { + VerificationOptions = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the options for the verification of the webhook request + /// + protected WebhookVerificationOptions VerificationOptions { get; } + + /// + /// Responds to the sender with a successful verification of the request. + /// + /// + /// The result of the verification of the request + /// + /// + /// The HTTP response object used to respond to the sender + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the response is sent + /// + protected async Task OnSuccessAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + httpResponse.StatusCode = VerificationOptions.SuccessStatusCode ?? 200; + + // TODO: Should we emit anything here? + await httpResponse.WriteAsync(""); + } + + /// + /// Responds to the sender with a failed verification of the request. + /// + /// + /// The failed result of the verification of the request + /// + /// + /// The HTTP response object used to respond to the sender + /// + /// + /// A token that can be used to cancel the operation + /// + /// + /// Returns a that completes when the response is sent + /// + protected async Task OnNotAuthenticatedAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + httpResponse.StatusCode = VerificationOptions.NotAuthenticatedStatusCode ?? 403; + + // TODO: Should we emit anything here? + await httpResponse.WriteAsync(""); + } + + /// + public virtual async Task HandleResultAsync(IWebhookVerificationResult result, HttpResponse httpResponse, CancellationToken cancellationToken) { + if (result.IsVerified) { + await OnSuccessAsync(result, httpResponse, cancellationToken); + } else { + await OnNotAuthenticatedAsync(result, httpResponse, cancellationToken); + } + } + + /// + /// Tries to get the verification token from the given request. + /// + /// + /// The HTTP request object that carries the data used for the verification + /// + /// + /// A string that contains the token, if found in the request + /// + /// + /// Returns true if the token is found in the request, or false otherwise + /// + protected virtual bool TryGetVerificationToken(HttpRequest request, [MaybeNullWhen(false)] out string? token) { + var verificationTokenQueryName = VerificationOptions.VerificationTokenQueryName; + + if (String.IsNullOrWhiteSpace(verificationTokenQueryName)) { + token = null; + return false; + } + + if (!request.Query.TryGetValue(verificationTokenQueryName, out var value)) { + token = null; + return false; + } + + token = value; + return true; + } + + /// + public virtual Task VerifyRequestAsync(HttpRequest httpRequest, CancellationToken cancellationToken = default) { + var verificationToken = VerificationOptions.VerificationToken; + + if (!TryGetVerificationToken(httpRequest, out var token) || + String.IsNullOrWhiteSpace(verificationToken) || + !String.Equals(token, verificationToken, StringComparison.Ordinal)) + return Task.FromResult(WebhookVerificationResult.Failed); + + return Task.FromResult(WebhookVerificationResult.Verified); + } + } +} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs similarity index 57% rename from src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs rename to src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs index c84a246..51f8bc9 100644 --- a/src/Deveel.Webhooks.Receiver/Webhooks/IWebhookReceiverBuilder.cs +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureLocation.cs @@ -1,4 +1,4 @@ -// Copyright 2022 Deveel +// Copyright 2022-2023 Deveel // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,10 +14,20 @@ using System; -using Microsoft.Extensions.DependencyInjection; - namespace Deveel.Webhooks { - public interface IWebhookReceiverBuilder { - IWebhookReceiverBuilder ConfigureServices(Action configure); + /// + /// Enumerates the possible locations where the signature of a webhook + /// can be found within a HTTP request object. + /// + public enum WebhookSignatureLocation { + /// + /// The signature is found in the HTTP header of the request. + /// + Header, + + /// + /// The signature is found in the query string of the request. + /// + QueryString } } diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs new file mode 100644 index 0000000..c36fd55 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookSignatureOptions.cs @@ -0,0 +1,47 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Provides the configuration settings used to verify the signature + /// of a webhook sent to the receiver. + /// + public class WebhookSignatureOptions { + /// + /// Gets or sets the location where the signature is found ( by default). + /// + public WebhookSignatureLocation Location { get; set; } = WebhookSignatureLocation.Header; + + /// + /// Gets or sets the name of the parameter that contains the signature. + /// + public string? ParameterName { get; set; } + + /// + /// Gets or sets the type of algorithm used to compute the signature. + /// + public string? Algorithm { get; set; } + + /// + /// Gets or sets the secret used to compute the signature. + /// + public string? Secret { get; set; } + + /// + /// Gets or sets the HTTP status code to return when the webhook + /// signature is invalid (400 by default). + /// + public int? InvalidStatusCode { get; set; } = 400; + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs new file mode 100644 index 0000000..a6dc764 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationOptions.cs @@ -0,0 +1,47 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; + +namespace Deveel.Webhooks { + /// + /// Provides the configuration options for the default verification + /// of a webhook request. + /// + public class WebhookVerificationOptions { + /// + /// Gets or sets a token that is matched against the value + /// sent by the provider to verify the identity of the receiver. + /// + public string? VerificationToken { get; set; } + + /// + /// Gets or sets the name of the query parameter that contains + /// the verification token. + /// + public string? VerificationTokenQueryName { get; set; } + + /// + /// Gets or sets the HTTP status code to return when the request + /// is successfully verified (204 by default). + /// + public int? SuccessStatusCode { get; set; } = 204; + + /// + /// Gets or sets the HTTP status code to return when the request + /// is not authenticated (403 by default). + /// + public int? NotAuthenticatedStatusCode { get; set; } = 403; + } +} diff --git a/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs new file mode 100644 index 0000000..f943357 --- /dev/null +++ b/src/Deveel.Webhooks.Receiver.AspNetCore/Webhooks/WebhookVerificationResult.cs @@ -0,0 +1,54 @@ +// Copyright 2022-2023 Deveel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Deveel.Webhooks { + /// + /// Represents a default implementation of a result + /// of the verification of a webhook request. + /// + public readonly struct WebhookVerificationResult : IWebhookVerificationResult { + /// + /// Constructs the result of a verification of a webhook request. + /// + /// + /// Whether the request is verified or not + /// + private WebhookVerificationResult(bool isVerified) { + IsVerified = isVerified; + } + + /// + /// Gets whether the request is verified or not. + /// + public bool IsVerified { get; } + + /// + /// Creates a new result of a successful verification of a webhook request + /// + /// + /// Returns an instance of that + /// represents a successful verification of a webhook request. + /// + public static WebhookVerificationResult Verified { get; } = new WebhookVerificationResult(true); + + /// + /// Creates a new result of a failed verification of a webhook request + /// + /// + /// Returns a new instance of that + /// represents a failed verification of a webhook request. + /// + public static WebhookVerificationResult Failed { get; } = new WebhookVerificationResult(false); + } +} diff --git a/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj b/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj deleted file mode 100644 index 555eb50..0000000 --- a/src/Deveel.Webhooks.Receiver/Deveel.Webhooks.Receiver.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net6.0 - 1.1.6 - Deveel - true - Antonello Provenzano - Deveel - Helper classes to receive webhooks. Although it should support other providers, this is primarly intended to support the webhooks produced through the Deveel framework - (C) 2020-2021 Deveel - LICENSE - deveel-logo.png - - https://github.com/deveel/deveel.webhooks - git - webhook receiver receivers http - - - - - - - - - - True - - - - True - - - - - diff --git a/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs b/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs deleted file mode 100644 index 1ef6a7c..0000000 --- a/src/Deveel.Webhooks.Receiver/System.Net.Http/HttpContentExtensions.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.IO; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace System.Net.Http { - public static class HttpContentExtensions { - public static Task ReadAsObjectAsync(this HttpContent content, Action afterRead, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(new JsonSerializerSettings(), afterRead, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(serializerSettings, null, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, Action afterRead) - where T : class - => content.ReadAsObjectAsync(serializerSettings, afterRead, default); - - public static Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings) - where T : class - => content.ReadAsObjectAsync(serializerSettings, default(CancellationToken)); - - public static Task ReadAsObjectAsync(this HttpContent content, CancellationToken cancellationToken) - where T : class - => content.ReadAsObjectAsync(new JsonSerializerSettings(), cancellationToken); - - public static async Task ReadAsObjectAsync(this HttpContent content, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) - where T : class { - - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await content.ReadAsJsonObjectAsync(cancellationToken); - - T result; - - try { - result = obj.ToObject(JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - } - - public static async Task ReadAsJsonObjectAsync(this HttpContent content, CancellationToken cancellationToken = default) { - var token = await content.ReadAsJsonAsync(cancellationToken); - - if (token.Type != JTokenType.Object) - throw new FormatException("The json request is invalid"); - - return (JObject)token; - } - - public static async Task ReadAsJsonAsync(this HttpContent content, CancellationToken cancellationToken = default) { - if (content.Headers == null) - throw new FormatException("Missing content headers"); - if (content.Headers.ContentType == null) - throw new FormatException("Content-type of the request is missing"); - - if (content.Headers.ContentType.MediaType != "application/json" && - content.Headers.ContentType.MediaType != "text/json") - throw new NotSupportedException("Only JSON webhooks supported at this moment"); - - // TODO: retrieve the encoding from the headers - - JToken token; - - using (var stream = await content.ReadAsStreamAsync()) { - using (var textReader = new StreamReader(stream, Encoding.UTF8)) { - using (var jsonReader = new JsonTextReader(textReader)) { - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - } - } - - return token; - } - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, Action afterRead) - => content.ReadAsObjectAsync(webhookType, serializerSettings, afterRead, default); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, serializerSettings, null, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, new JsonSerializerSettings(), cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, Action afterRead, CancellationToken cancellationToken) - => content.ReadAsObjectAsync(webhookType, new JsonSerializerSettings(), afterRead, cancellationToken); - - public static Task ReadAsObjectAsync(this HttpContent content, Type webhookType, Action afterRead) - => content.ReadAsObjectAsync(webhookType, afterRead, default); - - public static async Task ReadAsObjectAsync(this HttpContent content, Type webhookType, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) { - serializerSettings = serializerSettings ?? new JsonSerializerSettings(); - - var obj = await content.ReadAsJsonObjectAsync(cancellationToken); - - object result; - - try { - result = obj.ToObject(webhookType, JsonSerializer.Create(serializerSettings)); - } catch (Exception ex) { - throw new FormatException("The webhook JSON format is invalid", ex); - } - - afterRead?.Invoke(obj, result); - - return result; - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs b/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs deleted file mode 100644 index 5776cbd..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/DefaultHttptWebhookReceiver.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.Extensions.Options; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public class DefaultHttptWebhookReceiver : IHttpWebhookReceiver where T : class { - private readonly WebhookReceiveOptions options; - private readonly Action afterRead; - - public DefaultHttptWebhookReceiver(IOptions options) - : this(options, null) { - } - - public DefaultHttptWebhookReceiver(IOptions options, Action afterRead) - : this(options?.Value, afterRead) { - } - - public DefaultHttptWebhookReceiver(WebhookReceiveOptions options, Action afterRead = null) { - this.options = options; - this.afterRead = afterRead; - } - - public DefaultHttptWebhookReceiver() - : this(new WebhookReceiveOptions()) { - } - - protected virtual void OnAfterRead(JObject json, T obj) { - afterRead?.Invoke(json, obj); - } - - protected WebhookReceiveOptions Options { get; } - - public virtual Task ReceiveAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return request.GetWebhookAsync(Options, OnAfterRead, cancellationToken); - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs deleted file mode 100644 index 3ea8f75..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/HttpRequestMessageExtensions.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Web; - -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public static class HttpRequestMessageExtensions { - public static Task GetWebhookAsync(this HttpRequestMessage request, Action afterRead, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), afterRead, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(options, null, cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options) - where T : class - => request.GetWebhookAsync(options, default); - - public static Task GetWebhookAsync(this HttpRequestMessage request, CancellationToken cancellationToken) - where T : class - => request.GetWebhookAsync(new WebhookReceiveOptions(), cancellationToken); - - public static Task GetWebhookAsync(this HttpRequestMessage request) - where T : class - => request.GetWebhookAsync(default(CancellationToken)); - - public static async Task GetWebhookAsync(this HttpRequestMessage request, WebhookReceiveOptions options, Action afterRead, CancellationToken cancellationToken) - where T : class { - if (options != null && options.ValidateSignature) { - var content = await request.Content.ReadAsStringAsync(); - - if (!request.IsSignatureValid(content, options)) - throw new ArgumentException("The signature of the webhook is invalid"); - - return await WebhookJsonParser.ParseAsync(content, options.JsonSerializerSettings, afterRead, cancellationToken); - } - - return await request.Content.ReadAsObjectAsync(options?.JsonSerializerSettings, afterRead, cancellationToken); - } - - private static bool IsSignatureValid(this HttpRequestMessage request, string content, WebhookReceiveOptions options) { - string signature; - string algorithm = null; - - switch (options.SignatureLocation) { - case WebhookSignatureLocation.Header: - if (!request.Headers.TryGetValues(options.SignatureHeaderName, out var headerValue)) - return false; - - signature = headerValue.SingleOrDefault(); - - if (!string.IsNullOrEmpty(signature)) { - if (signature.StartsWith("sha256=")) { - signature = signature.Substring("sha256=".Length); - algorithm = "sha256"; - } - } - - break; - case WebhookSignatureLocation.QueryString: - if (string.IsNullOrWhiteSpace(request.RequestUri.Query)) - return false; - - var queryString = HttpUtility.ParseQueryString(request.RequestUri.Query); - signature = queryString[options.SignatureQueryStringKey]; - algorithm = queryString["sig_alg"]; - - break; - default: - // should never happen - throw new NotSupportedException(); - } - - if (string.IsNullOrWhiteSpace(signature)) - return false; - - return WebhookSignatureValidator.IsValid(algorithm, content, options.Secret, signature); - } - - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs deleted file mode 100644 index 865df5b..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class ServiceCollectionExtensions { - public static IServiceCollection AddWebhookReceivers(this IServiceCollection services, Action configure = null) { - - - if (configure != null) { - var builder = new WebhookReceiverConfigurationBuilder(services); - configure(builder); - } - - return services; - } - - class WebhookReceiverConfigurationBuilder : IWebhookReceiverBuilder { - public WebhookReceiverConfigurationBuilder(IServiceCollection services) { - Services = services; - } - - public IServiceCollection Services { get; } - - public IWebhookReceiverBuilder ConfigureServices(Action configure) { - if (configure != null) - configure(Services); - - return this; - } - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs deleted file mode 100644 index 8760253..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookJsonParser.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - public static class WebhookJsonParser { - public static async Task ParseAsync(string content, JsonSerializerSettings serializerSettings, Action afterRead, CancellationToken cancellationToken) { - JToken token; - - using (var textReader = new StringReader(content)) { - using var jsonReader = new JsonTextReader(textReader); - token = await JToken.ReadFromAsync(jsonReader, cancellationToken); - } - - var result = token.ToObject(JsonSerializer.Create(serializerSettings)); - - afterRead?.Invoke((JObject)token, result); - - return result; - } - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs deleted file mode 100644 index e3236b7..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookReceiverConfigurationBuilderExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; - -using Deveel.Webhooks; - -using Microsoft.Extensions.DependencyInjection; - -namespace Deveel.Webhooks { - public static class WebhookReceiverConfigurationBuilderExtensions { - public static IWebhookReceiverBuilder Configure(this IWebhookReceiverBuilder builder, Action configure) { - if (configure != null) - builder.ConfigureServices(services => services.Configure(configure)); - - return builder; - } - - public static IWebhookReceiverBuilder AddWebhookOptions(this IWebhookReceiverBuilder builder, WebhookReceiveOptions options) { - return builder.ConfigureServices(services => services.AddSingleton(options)); - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Scoped) - where TReceiver : class, IHttpWebhookReceiver - where TWebhook : class { - builder.ConfigureServices(services => { - services.Add(new ServiceDescriptor(typeof(IHttpWebhookReceiver), typeof(TReceiver), lifetime)); - services.Add(new ServiceDescriptor(typeof(TReceiver), lifetime)); - }); - - return builder; - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder, TReceiver receiver) - where TReceiver : class, IHttpWebhookReceiver - where TWebhook : class { - builder.ConfigureServices(services => services - .AddSingleton>(receiver) - .AddSingleton(receiver)); - return builder; - } - - public static IWebhookReceiverBuilder AddHttpReceiver(this IWebhookReceiverBuilder builder) - where T : class - => builder.AddHttpReceiver, T>(); - - } -} diff --git a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs b/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs deleted file mode 100644 index eba4cc0..0000000 --- a/src/Deveel.Webhooks.Receiver/Webhooks/WebhookSignatureValidator.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2022 Deveel -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Security.Cryptography; -using System.Text; - -namespace Deveel.Webhooks { - public static class WebhookSignatureValidator { - public static bool IsValid(string algorithm, string jsonPayload, string secret, string signature) { - if (string.IsNullOrWhiteSpace(signature)) - throw new ArgumentException($"'{nameof(signature)}' cannot be null or whitespace.", nameof(signature)); - if (string.IsNullOrWhiteSpace(secret)) - throw new ArgumentException($"'{nameof(secret)}' cannot be null or whitespace.", nameof(secret)); - if (string.IsNullOrWhiteSpace(algorithm)) - throw new ArgumentException($"'{nameof(algorithm)}' cannot be null or whitespace.", nameof(algorithm)); - - return algorithm switch { - "sha256" => IsValidSha256(jsonPayload, signature, secret), - _ => throw new NotSupportedException($"Te signing algorithm {algorithm} is not supported"), - }; - } - - public static bool IsValidSha256(string content, string signature, string secret) { - var secretBytes = Encoding.UTF8.GetBytes(secret); - - string compare; - - using (var hasher = new HMACSHA256(secretBytes)) { - var data = Encoding.UTF8.GetBytes(content); - var sha256 = hasher.ComputeHash(data); - - compare = BitConverter.ToString(sha256); - } - - return string.Equals(signature, compare); - } - } -} diff --git a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj index c707a29..1721c62 100644 --- a/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj +++ b/src/Deveel.Webhooks.Service.MongoDb/Deveel.Webhooks.MongoDb.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS An implementation of the Deveel Webhooks storage layer based on MongoDb - Copyright (C) 2021-2022 + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png @@ -33,7 +33,11 @@ - + + + + + diff --git a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj index 0cbd2d4..bc3a49d 100644 --- a/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj +++ b/src/Deveel.Webhooks.Service/Deveel.Webhooks.Service.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions and default services for the management Webhook subscriptions and their resolution - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel AS LICENSE https://deveel.com deveel-logo.png diff --git a/src/Deveel.Webhooks/Deveel.Webhooks.csproj b/src/Deveel.Webhooks/Deveel.Webhooks.csproj index b50bcc3..74ea4a6 100644 --- a/src/Deveel.Webhooks/Deveel.Webhooks.csproj +++ b/src/Deveel.Webhooks/Deveel.Webhooks.csproj @@ -2,14 +2,14 @@ net6.0 - 1.1.6 + 1.1.7 Deveel false true antonello - Deveel + Deveel AS Abstractions and utilities for the service management of webhooks senders - Copyright (C) 2021-2022 Deveel + Copyright (C) 2021-2023 Deveel LICENSE deveel-logo.png https://github.com/deveel/deveel.webhooks @@ -29,7 +29,11 @@ - + + + + + diff --git a/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs b/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs index 61ae30b..6dffaad 100644 --- a/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs +++ b/src/Deveel.Webhooks/Webhooks/Sha256WebhookSigner.cs @@ -34,7 +34,7 @@ public string Sign(string serializedBody, string secret) { var data = Encoding.UTF8.GetBytes(serializedBody); var sha256 = hasher.ComputeHash(data); - signature = BitConverter.ToString(sha256); + signature = Convert.ToBase64String(sha256); } return signature; diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj new file mode 100644 index 0000000..8409144 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Deveel.Webhooks.Receiver.TestApi.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + enable + enable + Deveel.Webhooks + + + + + + + + diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs new file mode 100644 index 0000000..ac40665 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/IWebhookCallback.cs @@ -0,0 +1,5 @@ +namespace Deveel.Webhooks.Handlers { + public interface IWebhookCallback { + void OnWebhookHandled(TWebhook? webhook); + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs new file mode 100644 index 0000000..dc02af8 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestSignedWebhookHandler.cs @@ -0,0 +1,19 @@ +using Deveel.Webhooks.Model; + +using Newtonsoft.Json; + +namespace Deveel.Webhooks.Handlers { + public class TestSignedWebhookHandler : IWebhookHandler { + private readonly IWebhookCallback _callback; + + public TestSignedWebhookHandler(IWebhookCallback callback) { + _callback = callback; + } + + public Task HandleAsync(TestSignedWebhook webhook, CancellationToken cancellationToken = default) { + _callback.OnWebhookHandled(webhook); + + return Task.CompletedTask; + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs new file mode 100644 index 0000000..bb03a7b --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookHandler.cs @@ -0,0 +1,20 @@ +using Deveel.Webhooks.Model; + +using Microsoft.Extensions.Options; + +namespace Deveel.Webhooks.Handlers { + public class TestWebhookHandler : IWebhookHandler { + private readonly WebhookReceiverOptions options; + private readonly IWebhookCallback callback; + + public TestWebhookHandler(IOptionsSnapshot options, IWebhookCallback callback) { + this.options = options.GetReceiverOptions(); + this.callback = callback; + } + + public Task HandleAsync(TestWebhook webhook, CancellationToken cancellationToken = default) { + callback.OnWebhookHandled(webhook); + return Task.CompletedTask; + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs new file mode 100644 index 0000000..1316745 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/TestWebhookReceiver.cs @@ -0,0 +1,11 @@ +using Deveel.Webhooks.Model; + +namespace Deveel.Webhooks.Handlers { + public class TestWebhookReceiver : IWebhookReceiver { + public async Task> ReceiveAsync(HttpRequest request, CancellationToken cancellationToken) { + // TODO: test the signature as well ... + + return await request.ReadFromJsonAsync(); + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs new file mode 100644 index 0000000..05c35ce --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Handlers/WebhookReceiverBuilderExtensions.cs @@ -0,0 +1,25 @@ +namespace Deveel.Webhooks.Handlers { + public static class WebhookReceiverBuilderExtensions { + public static WebhookReceiverBuilder UseCallback(this WebhookReceiverBuilder builder, IWebhookCallback callback) + where TWebhook : class { + builder.Services.AddSingleton(callback); + return builder; + } + + public static WebhookReceiverBuilder UseCallback(this WebhookReceiverBuilder builder, Action callback) + where TWebhook : class { + builder.Services.AddSingleton>(new DelegatedWebhookCallback(callback)); + return builder; + } + } + + class DelegatedWebhookCallback : IWebhookCallback where TWebhook : class { + private readonly Action callback; + + public DelegatedWebhookCallback(Action callback) { + this.callback = callback; + } + + public void OnWebhookHandled(TWebhook? webhook) => callback(webhook); + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs new file mode 100644 index 0000000..9a2b629 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestSignedWebhook.cs @@ -0,0 +1,4 @@ +namespace Deveel.Webhooks.Model { + public class TestSignedWebhook : TestWebhook { + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs new file mode 100644 index 0000000..87ffe46 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Model/TestWebhook.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Deveel.Webhooks.Model { + public class TestWebhook { + public string Id { get; set; } + + public string Event { get; set; } + + [JsonConverter(typeof(UnixDateTimeConverter))] + public DateTimeOffset TimeStamp { get; set; } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Program.cs b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs new file mode 100644 index 0000000..716cea0 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Program.cs @@ -0,0 +1,63 @@ +using Deveel.Webhooks.Handlers; +using Deveel.Webhooks.Model; + +namespace Deveel.Webhooks.Receiver.TestApi { + public class Program { + public static void Main(string[] args) { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddLogging(); + + // Add services to the container. + builder.Services.AddAuthorization(); + builder.Services + .AddWebhooks() + .UseNewtonsoftJsonParser() + .AddHandler(); + + var secret = builder.Configuration["Webhook:Receiver:Signature:Secret"]; + + builder.Services.AddWebhooks() + .Configure(options => { + options.VerifySignature = true; + options.Signature.Secret = secret; + options.Signature.ParameterName = "X-Webhook-Signature-256"; + options.Signature.Location = WebhookSignatureLocation.Header; + }) + .UseVerifier(options => { + options.VerificationToken = builder.Configuration["Webhook:Receiver:VerificationToken"]; + options.VerificationTokenQueryName = "token"; + }) + .UseNewtonsoftJsonParser() + .UseSha256Signer() + .AddHandler(); + + var app = builder.Build(); + + app.UseDeveloperExceptionPage(); + + // Configure the HTTP request pipeline. + + app.UseHttpsRedirection(); + + app.UseAuthorization(); + + app.UseWebhookReceiver("/webhook"); + app.UseWebhookReceiver("/webhook/handled", (context, webhook) => { + var callback = context.RequestServices.GetRequiredService>(); + + callback.OnWebhookHandled(webhook); + }); + + app.UseWebhookReceiver("/webhook/handled/async", async (context, webhook, token) => { + var callback = context.RequestServices.GetRequiredService>(); + callback.OnWebhookHandled(webhook); + }); + + app.UseWebhookVerfier("/webhook/signed"); + app.UseWebhookReceiver("/webhook/signed"); + + app.Run(); + } + } +} \ No newline at end of file diff --git a/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json b/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json new file mode 100644 index 0000000..b327b6a --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:1740", + "sslPort": 44341 + } + }, + "profiles": { + "Deveel.Webhooks.Receiver.TestApi": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7184;http://localhost:5257", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json new file mode 100644 index 0000000..33e2405 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore": "Trace" + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json new file mode 100644 index 0000000..db83a9b --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.TestApi/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Webhook": { + "Receiver": { + "VerificationToken": "NAP2fDWEDzdw5gXtESPyjSp", + "Signature": { + "Secret": "qjs62wtg155s7dd7exdgdj" + } + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj b/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj index 51e3726..95d5914 100644 --- a/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj +++ b/test/Deveel.Webhooks.Receiver.XUnit/Deveel.Webhooks.Receiver.XUnit.csproj @@ -8,18 +8,14 @@ Deveel - - - - - - all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -35,7 +31,7 @@ - + diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs deleted file mode 100644 index e9130a8..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/HttpWebhookReceiverTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using Xunit; - -namespace Deveel.Webhooks { - public class HttpWebhookReceiverTests { - private readonly IHttpWebhookReceiver httpReceiver; - private bool signed; - private WebhookSignatureLocation signatureLocation = WebhookSignatureLocation.QueryString; - - public HttpWebhookReceiverTests() { - var services = new ServiceCollection(); - - services.AddWebhookReceivers(webhook => webhook - .Configure(options => { - options.ValidateSignature = signed; - options.SignatureLocation = signatureLocation; - }) - .AddHttpReceiver()); - - var provider = services.BuildServiceProvider(); - httpReceiver = provider.GetRequiredService>(); - } - - [Fact] - public async Task ReceiveWebhookFromHttpRequestMessage() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "https://callback.deveel.com"); - requestMessage.Content = new StringContent(json.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); - - var received = await requestMessage.GetWebhookAsync(); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Fact] - public async Task ReceiveWebhookFromHttpReceiver() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var requestMessage = new HttpRequestMessage(HttpMethod.Post, "https://callback.deveel.com"); - requestMessage.Content = new StringContent(json.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); - - var received = await httpReceiver.ReceiveAsync(requestMessage, default); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Theory] - [InlineData(WebhookSignatureLocation.Header)] - [InlineData(WebhookSignatureLocation.QueryString)] - public async Task ReceiveSignedWebhookFromHttpRequest(WebhookSignatureLocation signatureLocation) { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var secret = Guid.NewGuid().ToString("N"); - - var options = new WebhookReceiveOptions { - Secret = secret, - ValidateSignature = true, - SignatureLocation = signatureLocation - }; - - var json = JObject.FromObject(webhook); - var jsonString = json.ToString(Newtonsoft.Json.Formatting.None); - var signature = new Sha256WebhookSigner().Sign(jsonString, secret); - - var requestUri = new UriBuilder("https://callback.deveel.com"); - - if (signatureLocation == WebhookSignatureLocation.QueryString) - requestUri.Query = $"?sig_alg=sha256&webhook-signature={signature}"; - - var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri.Uri); - - if (signatureLocation == WebhookSignatureLocation.Header) { - requestMessage.Headers.TryAddWithoutValidation(options.SignatureHeaderName, $"sha256={signature}"); - } - - requestMessage.Content = new StringContent(jsonString, Encoding.UTF8, "application/json"); - - var received = await requestMessage.GetWebhookAsync(options); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - class TestData { - public string Key { get; set; } - - public string Value { get; set; } - } - } -} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs deleted file mode 100644 index 7e812bc..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookPayload.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Deveel.Webhooks { - class WebhookPayload { - [JsonProperty("webhook")] - public string WebhookName { get; set; } - - [JsonProperty("event_id")] - public string EventId { get; set; } - - [JsonProperty("event_name")] - public string EventType { get; set; } - - - [JsonExtensionData] - public JObject Data { get; set; } - } -} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs new file mode 100644 index 0000000..1a494e8 --- /dev/null +++ b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiveRequestTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +using Deveel.Webhooks.Handlers; +using Deveel.Webhooks.Model; +using Deveel.Webhooks.Receiver.TestApi; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Newtonsoft.Json; + +using Xunit; +using Xunit.Abstractions; + +namespace Deveel.Webhooks { + public class WebhookReceiveRequestTests : IDisposable { + private readonly WebApplicationFactory appFactory; + private TestWebhook lastWebhook; + + public WebhookReceiveRequestTests(ITestOutputHelper outputHelper) { + appFactory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder + .ConfigureTestServices(ConfigureServices) + .ConfigureLogging(logging => logging.AddXUnit(outputHelper, opt => opt.Filter = (cat, level) => true) + .SetMinimumLevel(LogLevel.Trace))); + } + + private void ConfigureServices(IServiceCollection services) { + services.AddWebhooks() + .UseCallback(webhook => lastWebhook = webhook); + + services.AddWebhooks() + .UseCallback(webhook => lastWebhook = webhook); + } + + public void Dispose() => appFactory?.Dispose(); + + private HttpClient CreateClient() => appFactory.CreateClient(); + + [Fact] + public async Task ReceiveTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.NotNull(lastWebhook); + Assert.Equal("test", lastWebhook.Event); + } + + [Fact] + public async Task ReceiveHandledTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook/handled") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + Assert.NotNull(lastWebhook); + } + + [Fact] + public async Task ReceiveAsyncHandledTestWebhook() { + var client = CreateClient(); + + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "/webhook/handled/async") { + Content = new StringContent(JsonConvert.SerializeObject(new TestWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }), Encoding.UTF8, "application/json") + }); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + + + private string GetSha256Signature(string json) { + var config = appFactory.Services.GetRequiredService(); + + var secret = config["Webhook:Receiver:Signature:Secret"]; + + var sha256Signer = new Sha256WebhookSigner(); + return sha256Signer.SignWebhook(json, secret); + } + + [Fact] + public async Task ReceiveSignedTestWebhook() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var sha256Sig = GetSha256Signature(json); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + request.Headers.TryAddWithoutValidation("X-Webhook-Signature-256", sha256Sig); + + var response = await client.SendAsync(request); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task ReceiveSignedTestWebhook_InvalidSignature() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var sha256Sig = GetSha256Signature(json + "..."); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + request.Headers.TryAddWithoutValidation("X-Webhook-Signature-256", sha256Sig); + + var response = await client.SendAsync(request); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ReceiveSignedTestWebhook_NoSignature() { + var client = CreateClient(); + + var json = JsonConvert.SerializeObject(new TestSignedWebhook { + Id = Guid.NewGuid().ToString("N"), + Event = "test", + TimeStamp = DateTimeOffset.Now, + }); + + var request = new HttpRequestMessage(HttpMethod.Post, "/webhook/signed") { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task VerifyReceiver() { + var client = CreateClient(); + + var token = appFactory.Services.GetRequiredService()["Webhook:Receiver:VerificationToken"]; + + var response = await client.GetAsync($"/webhook/signed?token={token}"); + + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task VerifyReceiver_InvalidToken() { + var client = CreateClient(); + + var response = await client.GetAsync($"/webhook/signed?token={Guid.NewGuid().ToString("N")}"); + + Assert.False (response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } +} diff --git a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs b/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs deleted file mode 100644 index 4fb148a..0000000 --- a/test/Deveel.Webhooks.Receiver.XUnit/Webhooks/WebhookReceiverTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Internal; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.VisualStudio.TestPlatform.Common.ExtensionFramework; -using Microsoft.VisualStudio.TestPlatform.Common.Interfaces; - -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -using Xunit; - -namespace Deveel.Webhooks { - public class WebhookReceiverTests { - private readonly IWebhookReceiver receiver; - private bool signed; - private WebhookSignatureLocation signatureLocation = WebhookSignatureLocation.QueryString; - - public WebhookReceiverTests() { - var services = new ServiceCollection(); - services.AddWebhookReceivers(webhook => webhook - .Configure(options => { - options.ValidateSignature = signed; - options.SignatureLocation = signatureLocation; - }) - .AddReceiver()); - - var provider = services.BuildServiceProvider(); - receiver = provider.GetRequiredService>(); - } - - [Fact] - public async Task ReceiveWebhookFromRequest() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - context.Request.Body = stream; - - var received = await context.Request.GetWebhookAsync(); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - [Fact] - public async Task ReceiveWebhookFromReceiver() { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var json = JObject.FromObject(webhook); - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - context.Request.Body = stream; - - var received = await receiver.ReceiveAsync(context.Request, default); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - - [Theory] - [InlineData(WebhookSignatureLocation.Header)] - [InlineData(WebhookSignatureLocation.QueryString)] - public async Task ReceiveSignedWebhook(WebhookSignatureLocation signatureLocation) { - var webhook = new WebhookPayload { - WebhookName = "Test Webhook", - EventId = Guid.NewGuid().ToString("N"), - EventType = "event.occurred", - Data = JObject.FromObject(new TestData { - Key = "foo", - Value = "bar" - }) - }; - - var secret = Guid.NewGuid().ToString("N"); - - var options = new WebhookReceiveOptions { - Secret = secret, - ValidateSignature = true, - SignatureLocation = signatureLocation - }; - - var json = JObject.FromObject(webhook); - - var signature = new Sha256WebhookSigner().Sign(json.ToString(Formatting.None), secret); - - var stream = new MemoryStream(); - - var writer = new StreamWriter(stream); - var jsonWriter = new JsonTextWriter(writer); - await json.WriteToAsync(jsonWriter); - await jsonWriter.FlushAsync(); - - stream.Position = 0; - - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Method = "POST"; - if (signatureLocation == WebhookSignatureLocation.QueryString) { - context.Request.QueryString = new QueryString($"?sig_alg=sha256&webhook-signature={signature}"); - } else { - context.Request.Headers.TryAdd(options.SignatureHeaderName, $"sha256={signature}"); - } - - context.Request.Body = stream; - - var received = await context.Request.GetWebhookAsync(options); - - Assert.NotNull(received); - Assert.Equal(webhook.EventId, received.EventId); - Assert.Equal(webhook.EventType, received.EventType); - Assert.NotNull(webhook.Data); - Assert.Equal("foo", webhook.Data["Key"].Value()); - Assert.Equal("bar", webhook.Data["Value"].Value()); - } - - class TestData { - public string Key { get; set; } - - public string Value { get; set; } - } - } -}