├── .github ├── FUNDING.yml └── workflows │ ├── cd-aspnet-extensions.yml │ ├── cd-azure-functions-worker-extensions.yml │ ├── cd-azure-webjobs-extensions.yml │ ├── cd-net-http.yml │ ├── ci.yml │ └── gh-pages.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── Lib.Net.Http.WebPush.sln ├── README.md ├── benchmarks └── Benchmark.Net.Http.WebPush │ ├── Benchmark.Net.Http.WebPush.csproj │ ├── Program.cs │ └── WebPushBenchmarks.cs ├── docs └── DocFx.Net.Http.WebPush │ ├── DocFx.Net.Http.WebPush.csproj │ ├── Program.cs │ ├── Startup.cs │ ├── articles │ ├── aspnetcore-integration.md │ ├── azure-functions-integration.md │ └── performance-considerations.md │ ├── docfx.json │ ├── index.md │ ├── resources │ ├── ico │ │ └── favicon.ico │ └── svg │ │ └── logo.svg │ └── toc.md ├── src ├── Lib.AspNetCore.WebPush │ ├── Caching │ │ ├── DistributedVapidTokenCache.cs │ │ └── MemoryVapidTokenCache.cs │ ├── Lib.AspNetCore.WebPush.csproj │ ├── Lib.AspNetCore.WebPush.snk │ ├── PushServiceClientOptions.cs │ └── PushServiceClientServiceCollectionExtensions.cs ├── Lib.Azure.Functions.Worker.Extensions.WebPush │ ├── Constants.cs │ ├── FunctionsWorkerApplicationBuilderExtensions.cs │ ├── Lib.Azure.Functions.Worker.Extensions.WebPush.csproj │ ├── Properties │ │ └── AssemblyInfo.cs │ ├── PushServiceConverter.cs │ ├── PushServiceExtensionStartup.cs │ ├── PushServiceInputAttribute.cs │ └── PushServiceModelBindingDataContent.cs ├── Lib.Azure.WebJobs.Extensions.WebPush │ ├── Bindings │ │ ├── PushServiceAttribute.cs │ │ ├── PushServiceClientConverter.cs │ │ └── PushServiceParameterBindingDataContent.cs │ ├── Config │ │ └── PushServiceExtensionConfigProvider.cs │ ├── Constants.cs │ ├── Lib.Azure.WebJobs.Extensions.WebPush.csproj │ ├── Lib.Azure.WebJobs.Extensions.WebPush.snk │ ├── PushServiceOptions.cs │ ├── PushServiceWebJobsBuilderExtensions.cs │ └── PushServiceWebJobsStartup.cs └── Lib.Net.Http.WebPush │ ├── Authentication │ ├── IVapidTokenCache.cs │ ├── VapidAuthentication.cs │ └── VapidAuthenticationScheme.cs │ ├── Internals │ ├── ECDHAgreement.cs │ ├── ECDHAgreementCalculator.BouncyCastle.cs │ ├── ECDHAgreementCalculator.NET.cs │ ├── ES256Signer.BouncyCastle.cs │ ├── ES256Signer.NET.cs │ └── UrlBase64Converter.cs │ ├── Lib.Net.Http.WebPush.csproj │ ├── Lib.Net.Http.WebPush.snk │ ├── PushEncryptionKeyName.cs │ ├── PushMessage.cs │ ├── PushMessageUrgency.cs │ ├── PushServiceClient.cs │ ├── PushServiceClientException.cs │ └── PushSubscription.cs └── test └── Test.Lib.Net.Http.WebPush ├── Functional ├── Infrastructure │ ├── FakePushServiceApplicationFactory.cs │ └── FakePushServiceStartup.cs └── PushMessageDeliveryTests.cs ├── Test.Lib.Net.Http.WebPush.csproj └── Unit └── PushSubscriptionTests.cs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tpeczek 2 | -------------------------------------------------------------------------------- /.github/workflows/cd-aspnet-extensions.yml: -------------------------------------------------------------------------------- 1 | name: Lib.AspNetCore.WebPush - CD 2 | on: 3 | push: 4 | tags: 5 | - "aspnet-extensions-v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | deployment: 8 | runs-on: windows-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Extract VERSION 13 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/aspnet-extensions-v/}" >> $GITHUB_ENV 14 | shell: bash 15 | - name: Setup .NET Core 3.1 SDK 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: '3.1.x' 19 | - name: Setup .NET 5.0 SDK 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '5.0.x' 23 | - name: Setup .NET 6.0 SDK 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Setup .NET 8.0 SDK 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '8.0.x' 31 | - name: Setup .NET 9.0 SDK 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: '9.0.x' 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | - name: Test 40 | run: dotnet test --configuration Release --no-build 41 | - name: Pack 42 | run: dotnet pack --configuration Release --no-build 43 | - name: NuGet Push Lib.AspNetCore.WebPush 44 | run: dotnet nuget push src/Lib.AspNetCore.WebPush/bin/Release/Lib.AspNetCore.WebPush.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} 45 | shell: bash 46 | env: 47 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} -------------------------------------------------------------------------------- /.github/workflows/cd-azure-functions-worker-extensions.yml: -------------------------------------------------------------------------------- 1 | name: Lib.Azure.Functions.Worker.Extensions.WebPush - CD 2 | on: 3 | push: 4 | tags: 5 | - "azure-functions-worker-extensions-v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | deployment: 8 | runs-on: windows-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Extract VERSION 13 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/azure-functions-worker-extensions-v/}" >> $GITHUB_ENV 14 | shell: bash 15 | - name: Setup .NET Core 3.1 SDK 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: '3.1.x' 19 | - name: Setup .NET 5.0 SDK 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '5.0.x' 23 | - name: Setup .NET 6.0 SDK 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Setup .NET 8.0 SDK 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '8.0.x' 31 | - name: Setup .NET 9.0 SDK 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: '9.0.x' 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | - name: Test 40 | run: dotnet test --configuration Release --no-build 41 | - name: Pack 42 | run: dotnet pack --configuration Release --no-build 43 | - name: NuGet Push Lib.Azure.Functions.Worker.Extensions.WebPush 44 | run: dotnet nuget push src/Lib.Azure.Functions.Worker.Extensions.WebPush/bin/Release/Lib.Azure.Functions.Worker.Extensions.WebPush.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} 45 | shell: bash 46 | env: 47 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} -------------------------------------------------------------------------------- /.github/workflows/cd-azure-webjobs-extensions.yml: -------------------------------------------------------------------------------- 1 | name: Lib.Azure.WebJobs.Extensions.WebPush - CD 2 | on: 3 | push: 4 | tags: 5 | - "azure-webjobs-extensions-v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | deployment: 8 | runs-on: windows-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Extract VERSION 13 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/azure-webjobs-extensions-v/}" >> $GITHUB_ENV 14 | shell: bash 15 | - name: Setup .NET Core 3.1 SDK 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: '3.1.x' 19 | - name: Setup .NET 5.0 SDK 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '5.0.x' 23 | - name: Setup .NET 6.0 SDK 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Setup .NET 8.0 SDK 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '8.0.x' 31 | - name: Setup .NET 9.0 SDK 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: '9.0.x' 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | - name: Test 40 | run: dotnet test --configuration Release --no-build 41 | - name: Pack 42 | run: dotnet pack --configuration Release --no-build 43 | - name: NuGet Push Lib.Azure.WebJobs.Extensions.WebPush 44 | run: dotnet nuget push src/Lib.Azure.WebJobs.Extensions.WebPush/bin/Release/Lib.Azure.WebJobs.Extensions.WebPush.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} 45 | shell: bash 46 | env: 47 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} -------------------------------------------------------------------------------- /.github/workflows/cd-net-http.yml: -------------------------------------------------------------------------------- 1 | name: Lib.Net.Http.WebPush - CD 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | deployment: 8 | runs-on: windows-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Extract VERSION 13 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 14 | shell: bash 15 | - name: Setup .NET Core 3.1 SDK 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: '3.1.x' 19 | - name: Setup .NET 5.0 SDK 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '5.0.x' 23 | - name: Setup .NET 6.0 SDK 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Setup .NET 8.0 SDK 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '8.0.x' 31 | - name: Setup .NET 9.0 SDK 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: '9.0.x' 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | - name: Test 40 | run: dotnet test --configuration Release --no-build 41 | - name: Pack 42 | run: dotnet pack --configuration Release --no-build 43 | - name: NuGet Push Lib.Net.Http.WebPush 44 | run: dotnet nuget push src/Lib.Net.Http.WebPush/bin/Release/Lib.Net.Http.WebPush.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} 45 | shell: bash 46 | env: 47 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | integration: 11 | runs-on: windows-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Setup .NET Core 3.1 SDK 16 | uses: actions/setup-dotnet@v4 17 | with: 18 | dotnet-version: '3.1.x' 19 | - name: Setup .NET 5.0 SDK 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: '5.0.x' 23 | - name: Setup .NET 6.0 SDK 24 | uses: actions/setup-dotnet@v4 25 | with: 26 | dotnet-version: '6.0.x' 27 | - name: Setup .NET 8.0 SDK 28 | uses: actions/setup-dotnet@v4 29 | with: 30 | dotnet-version: '8.0.x' 31 | - name: Setup .NET 9.0 SDK 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: '9.0.x' 35 | - name: Restore 36 | run: dotnet restore 37 | - name: Build 38 | run: dotnet build --configuration Release --no-restore 39 | - name: Test 40 | run: dotnet test --configuration Release --no-build -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: DocFx 2 | on: workflow_dispatch 3 | jobs: 4 | build: 5 | runs-on: windows-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v4 9 | - name: Setup .NET Core 3.1 SDK 10 | uses: actions/setup-dotnet@v4 11 | with: 12 | dotnet-version: '3.1.x' 13 | - name: Setup .NET 5.0 SDK 14 | uses: actions/setup-dotnet@v4 15 | with: 16 | dotnet-version: '5.0.x' 17 | - name: Setup .NET 6.0 SDK 18 | uses: actions/setup-dotnet@v4 19 | with: 20 | dotnet-version: '6.0.x' 21 | - name: Setup .NET 8.0 SDK 22 | uses: actions/setup-dotnet@v4 23 | with: 24 | dotnet-version: '8.0.x' 25 | - name: Setup .NET 9.0 SDK 26 | uses: actions/setup-dotnet@v4 27 | with: 28 | dotnet-version: '9.0.x' 29 | - name: Restore 30 | run: dotnet restore 31 | - name: Build 32 | run: dotnet build --configuration Release --no-restore 33 | - name: Upload Artifacts 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: docfx 37 | path: docs/DocFx.Net.Http.WebPush/wwwroot 38 | retention-days: 1 39 | publish: 40 | needs: [build] 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | - name: Download Artifacts 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: docfx 49 | path: docs/DocFx.Net.Http.WebPush/wwwroot 50 | - name: Publish 51 | uses: JamesIves/github-pages-deploy-action@4.1.5 52 | with: 53 | branch: gh-pages 54 | folder: docs/DocFx.Net.Http.WebPush/wwwroot -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # DocFX generated stuff 146 | docs/DocFx.Net.Http.WebPush/log.txt 147 | docs/DocFx.Net.Http.WebPush/api/ 148 | docs/DocFx.Net.Http.WebPush/wwwroot/ 149 | 150 | # Click-Once directory 151 | publish/ 152 | 153 | # Publish Web Output 154 | *.[Pp]ublish.xml 155 | *.azurePubxml 156 | # TODO: Comment the next line if you want to checkin your web deploy settings 157 | # but database connection strings (with potential passwords) will be unencrypted 158 | *.pubxml 159 | *.publishproj 160 | 161 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 162 | # checkin your Azure Web App publish settings, but sensitive information contained 163 | # in these scripts will be unencrypted 164 | PublishScripts/ 165 | 166 | # NuGet Packages 167 | *.nupkg 168 | # The packages folder can be ignored because of Package Restore 169 | **/packages/* 170 | # except build/, which is used as an MSBuild target. 171 | !**/packages/build/ 172 | # Uncomment if necessary however generally it will be regenerated when needed 173 | #!**/packages/repositories.config 174 | # NuGet v3's project.json files produces more ignorable files 175 | *.nuget.props 176 | *.nuget.targets 177 | 178 | # Microsoft Azure Build Output 179 | csx/ 180 | *.build.csdef 181 | 182 | # Microsoft Azure Emulator 183 | ecf/ 184 | rcf/ 185 | 186 | # Windows Store app package directories and files 187 | AppPackages/ 188 | BundleArtifacts/ 189 | Package.StoreAssociation.xml 190 | _pkginfo.txt 191 | 192 | # Visual Studio cache files 193 | # files ending in .cache can be ignored 194 | *.[Cc]ache 195 | # but keep track of directories ending in .cache 196 | !*.[Cc]ache/ 197 | 198 | # Others 199 | ClientBin/ 200 | ~$* 201 | *~ 202 | *.dbmdl 203 | *.dbproj.schemaview 204 | *.jfm 205 | *.pfx 206 | *.publishsettings 207 | orleans.codegen.cs 208 | 209 | # Since there are multiple workflows, uncomment next line to ignore bower_components 210 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 211 | #bower_components/ 212 | 213 | # RIA/Silverlight projects 214 | Generated_Code/ 215 | 216 | # Backup & report files from converting an old project file 217 | # to a newer Visual Studio version. Backup files are not needed, 218 | # because we have git ;-) 219 | _UpgradeReport_Files/ 220 | Backup*/ 221 | UpgradeLog*.XML 222 | UpgradeLog*.htm 223 | 224 | # SQL Server files 225 | *.mdf 226 | *.ldf 227 | *.ndf 228 | 229 | # Business Intelligence projects 230 | *.rdl.data 231 | *.bim.layout 232 | *.bim_*.settings 233 | 234 | # Microsoft Fakes 235 | FakesAssemblies/ 236 | 237 | # GhostDoc plugin setting file 238 | *.GhostDoc.xml 239 | 240 | # Node.js Tools for Visual Studio 241 | .ntvs_analysis.dat 242 | node_modules/ 243 | 244 | # Typescript v1 declaration files 245 | typings/ 246 | 247 | # Visual Studio 6 build log 248 | *.plg 249 | 250 | # Visual Studio 6 workspace options file 251 | *.opt 252 | 253 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 254 | *.vbw 255 | 256 | # Visual Studio LightSwitch build output 257 | **/*.HTMLClient/GeneratedArtifacts 258 | **/*.DesktopClient/GeneratedArtifacts 259 | **/*.DesktopClient/ModelManifest.xml 260 | **/*.Server/GeneratedArtifacts 261 | **/*.Server/ModelManifest.xml 262 | _Pvt_Extensions 263 | 264 | # Paket dependency manager 265 | .paket/paket.exe 266 | paket-files/ 267 | 268 | # FAKE - F# Make 269 | .fake/ 270 | 271 | # JetBrains Rider 272 | .idea/ 273 | *.sln.iml 274 | 275 | # CodeRush 276 | .cr/ 277 | 278 | # Python Tools for Visual Studio (PTVS) 279 | __pycache__/ 280 | *.pyc 281 | 282 | # Cake - Uncomment if you are using it 283 | # tools/** 284 | # !tools/packages.config 285 | 286 | # Telerik's JustMock configuration file 287 | *.jmconfig 288 | 289 | # BizTalk build output 290 | *.btp.cs 291 | *.btm.cs 292 | *.odx.cs 293 | *.xsd.cs 294 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Lib.Net.Http.WebPush 3.3.1 2 | ### Bug Fixes 3 | - Fix for `RequestPushMessageDeliveryAsync` unable to write content to request stream for empty content. 4 | 5 | ## Lib.Net.Http.WebPush 3.3.0 6 | ### Additions and Changes 7 | - Added support for Mozilla Autopush Server return codes 8 | 9 | ## Lib.Net.Http.WebPush 3.2.1 10 | ### Bug Fixes 11 | - Fix for client keys casing and validation. 12 | 13 | ## Lib.Net.Http.WebPush 3.2.0 14 | ### Additions and Changes 15 | - Dropped support for .NET Core 2.1 16 | - Added support for .NET 6.0 17 | 18 | ## Lib.Net.Http.WebPush 3.1.0 19 | ### Additions and Changes 20 | - Dropped support for .NET Standard 1.6 21 | - Removed dependency on Bouncy Castle for .NET 5 22 | - `VapidAuthentication` now implements `IDisposable` 23 | 24 | ## Lib.Net.Http.WebPush 2.3.0 25 | ### Bug Fixes 26 | - Fix for incorrect reuse of internally created `HttpContent` 27 | - Fix for incorrect reuse of internally created `HttpRequestMessage` 28 | ### Additions and Changes 29 | - Added context information to `PushServiceClientException` (thank you @techfg) 30 | - Added setting for maximum limit of automatic retries in case of 429 Too Many Requests 31 | - Exposed `AutoRetryAfter`, `DefaultTimeToLive`, and `MaxRetriesAfter` in Azure Functions binding 32 | 33 | ## Lib.Net.Http.WebPush 2.2.0 34 | ### Additions and Changes 35 | - Added `HttpContent` based constructor for `PushMessage` 36 | 37 | ## Lib.Net.Http.WebPush 2.1.0 38 | ### Additions and Changes 39 | - Automatic support for retries in case of 429 Too Many Requests 40 | 41 | ## Lib.Net.Http.WebPush 2.0.0 42 | ### Additions and Changes 43 | - Strong named the assembly 44 | - Changed default authentication scheme to VAPID 45 | 46 | ## Lib.Net.Http.WebPush 1.5.0 47 | ### Bug Fixes 48 | - Fix for *Length Required* issue in case of push message delivery request to MS Edge 49 | 50 | ## Lib.Net.Http.WebPush 1.4.0 51 | ### Additions and Changes 52 | - Added support for .NET Standard 2.0 53 | 54 | ## Lib.Net.Http.WebPush 1.3.0 55 | ### Bug Fixes 56 | - Fix for push messages urgency 57 | ### Additions and Changes 58 | - Added constructor which accepts instance of `HttpClient` 59 | - Minor performance improvements 60 | 61 | ## Lib.Net.Http.WebPush 1.2.0 62 | ### Additions and Changes 63 | - Added support for push messages topic 64 | - Added support for push messages urgency 65 | 66 | ## Lib.Net.Http.WebPush 1.1.0 67 | ### Additions and Changes 68 | - Added VAPID tokens caching capability 69 | - Added support for both VAPID schemes -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 - 2025 Tomasz Pęczek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Lib.Net.Http.WebPush.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32505.173 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lib.Net.Http.WebPush", "src\Lib.Net.Http.WebPush\Lib.Net.Http.WebPush.csproj", "{79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DocFx.Net.Http.WebPush", "docs\DocFx.Net.Http.WebPush\DocFx.Net.Http.WebPush.csproj", "{91C4FA65-7104-494C-9ED8-4B886BC2F7CD}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmark.Net.Http.WebPush", "benchmarks\Benchmark.Net.Http.WebPush\Benchmark.Net.Http.WebPush.csproj", "{0100508D-6C06-443B-9893-91F2F15DC920}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lib.Azure.WebJobs.Extensions.WebPush", "src\Lib.Azure.WebJobs.Extensions.WebPush\Lib.Azure.WebJobs.Extensions.WebPush.csproj", "{001A4013-EE58-428A-BD6C-49AC3A0CA6F9}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0441724F-48F7-4863-BBA6-586E10182605}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C24A444A-8A7A-4BE1-84C4-3E9755D5F9BE}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{10B2C3B8-F5B5-49D4-ADA2-48FE376B758A}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lib.AspNetCore.WebPush", "src\Lib.AspNetCore.WebPush\Lib.AspNetCore.WebPush.csproj", "{D9CF3828-E0F8-4A92-97D3-4B79E9C38F58}" 21 | EndProject 22 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{AFD9AC51-E923-49DF-B000-721816ABDE37}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Test.Lib.Net.Http.WebPush", "test\Test.Lib.Net.Http.WebPush\Test.Lib.Net.Http.WebPush.csproj", "{E0746BE4-61F3-4366-8C7E-99C2350A0DD3}" 25 | EndProject 26 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{7992C65C-E6C3-4B34-AB9B-EF24915BB907}" 27 | ProjectSection(SolutionItems) = preProject 28 | CHANGELOG.md = CHANGELOG.md 29 | README.md = README.md 30 | EndProjectSection 31 | EndProject 32 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lib.Azure.Functions.Worker.Extensions.WebPush", "src\Lib.Azure.Functions.Worker.Extensions.WebPush\Lib.Azure.Functions.Worker.Extensions.WebPush.csproj", "{FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D}" 33 | EndProject 34 | Global 35 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 36 | Debug|Any CPU = Debug|Any CPU 37 | Release|Any CPU = Release|Any CPU 38 | EndGlobalSection 39 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 40 | {79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {91C4FA65-7104-494C-9ED8-4B886BC2F7CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {91C4FA65-7104-494C-9ED8-4B886BC2F7CD}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {91C4FA65-7104-494C-9ED8-4B886BC2F7CD}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {91C4FA65-7104-494C-9ED8-4B886BC2F7CD}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {0100508D-6C06-443B-9893-91F2F15DC920}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {0100508D-6C06-443B-9893-91F2F15DC920}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {0100508D-6C06-443B-9893-91F2F15DC920}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {0100508D-6C06-443B-9893-91F2F15DC920}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {001A4013-EE58-428A-BD6C-49AC3A0CA6F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {001A4013-EE58-428A-BD6C-49AC3A0CA6F9}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {001A4013-EE58-428A-BD6C-49AC3A0CA6F9}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {001A4013-EE58-428A-BD6C-49AC3A0CA6F9}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {D9CF3828-E0F8-4A92-97D3-4B79E9C38F58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {D9CF3828-E0F8-4A92-97D3-4B79E9C38F58}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {D9CF3828-E0F8-4A92-97D3-4B79E9C38F58}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {D9CF3828-E0F8-4A92-97D3-4B79E9C38F58}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {E0746BE4-61F3-4366-8C7E-99C2350A0DD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {E0746BE4-61F3-4366-8C7E-99C2350A0DD3}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {E0746BE4-61F3-4366-8C7E-99C2350A0DD3}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {E0746BE4-61F3-4366-8C7E-99C2350A0DD3}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D}.Release|Any CPU.Build.0 = Release|Any CPU 68 | EndGlobalSection 69 | GlobalSection(SolutionProperties) = preSolution 70 | HideSolutionNode = FALSE 71 | EndGlobalSection 72 | GlobalSection(NestedProjects) = preSolution 73 | {79FDCAAA-6129-4FBB-B94E-7F7BE9A07C7C} = {0441724F-48F7-4863-BBA6-586E10182605} 74 | {91C4FA65-7104-494C-9ED8-4B886BC2F7CD} = {C24A444A-8A7A-4BE1-84C4-3E9755D5F9BE} 75 | {0100508D-6C06-443B-9893-91F2F15DC920} = {10B2C3B8-F5B5-49D4-ADA2-48FE376B758A} 76 | {001A4013-EE58-428A-BD6C-49AC3A0CA6F9} = {0441724F-48F7-4863-BBA6-586E10182605} 77 | {D9CF3828-E0F8-4A92-97D3-4B79E9C38F58} = {0441724F-48F7-4863-BBA6-586E10182605} 78 | {E0746BE4-61F3-4366-8C7E-99C2350A0DD3} = {AFD9AC51-E923-49DF-B000-721816ABDE37} 79 | {FF54B26A-8E32-4D2F-B91E-C8BAAFE9148D} = {0441724F-48F7-4863-BBA6-586E10182605} 80 | EndGlobalSection 81 | GlobalSection(ExtensibilityGlobals) = postSolution 82 | SolutionGuid = {88535C7E-5564-4AC1-A41B-18DD05637943} 83 | EndGlobalSection 84 | EndGlobal 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lib.Net.Http.WebPush 2 | 3 | [![NuGet Version](https://img.shields.io/nuget/v/Lib.Net.Http.WebPush?label=Lib.Net.Http.WebPush&logo=nuget)](https://www.nuget.org/packages/Lib.Net.Http.WebPush) 4 | [![NuGet Downloads](https://img.shields.io/nuget/dt/Lib.Net.Http.WebPush?label=⭳)](https://www.nuget.org/packages/Lib.Net.Http.WebPush) 5 | 6 | [![NuGet Version](https://img.shields.io/nuget/v/Lib.AspNetCore.WebPush?label=Lib.AspNetCore.WebPush&logo=nuget)](https://www.nuget.org/packages/Lib.AspNetCore.WebPush) 7 | [![NuGet Downloads](https://img.shields.io/nuget/dt/Lib.AspNetCore.WebPush?label=⭳)](https://www.nuget.org/packages/Lib.AspNetCore.WebPush) 8 | 9 | [![NuGet Version](https://img.shields.io/nuget/v/Lib.Azure.WebJobs.Extensions.WebPush?label=Lib.Azure.WebJobs.Extensions.WebPush&logo=nuget)](https://www.nuget.org/packages/Lib.Azure.WebJobs.Extensions.WebPush) 10 | [![NuGet Downloads](https://img.shields.io/nuget/dt/Lib.Azure.WebJobs.Extensions.WebPush?label=⭳)](https://www.nuget.org/packages/Lib.Azure.WebJobs.Extensions.WebPush) 11 | 12 | [![NuGet Version](https://img.shields.io/nuget/v/Lib.Azure.Functions.Worker.Extensions.WebPush?label=Lib.Azure.Functions.Worker.Extensions.WebPush&logo=nuget)](https://www.nuget.org/packages/Lib.Azure.Functions.Worker.Extensions.WebPush) 13 | [![NuGet Downloads](https://img.shields.io/nuget/dt/Lib.Azure.Functions.Worker.Extensions.WebPush?label=⭳)](https://www.nuget.org/packages/Lib.Azure.Functions.Worker.Extensions.WebPush) 14 | 15 | Lib.Net.Http.WebPush is a library which provides a [Web Push Protocol](https://tools.ietf.org/html/rfc8030) based client for Push Service. It provides support for [Voluntary Application Server Identification (VAPID) for Web Push](https://tools.ietf.org/html/rfc8292) and [Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291). 16 | 17 | Lib.AspNetCore.WebPush is a library which provides ASP.NET Core extensions for Web Push Protocol based client for Push Service. 18 | 19 | Lib.Azure.WebJobs.Extensions.WebPush is a library which provides [Azure Functions](https://functions.azure.com/) in-process model and [Azure WebJobs](https://docs.microsoft.com/en-us/azure/app-service/web-sites-create-web-jobs) binding extensions for Web Push Protocol based client for Push Service. 20 | 21 | Lib.Azure.Functions.Worker.Extensions.WebPush is a library which provides [Azure Functions](https://functions.azure.com/) isolated worker model extensions for Web Push Protocol based client for Push Service. 22 | 23 | ## Installation 24 | 25 | You can install [Lib.Net.Http.WebPush](https://www.nuget.org/packages/Lib.Net.Http.WebPush), [Lib.AspNetCore.WebPush](https://www.nuget.org/packages/Lib.AspNetCore.WebPush), [Lib.Azure.WebJobs.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.WebJobs.Extensions.WebPush), and [Lib.Azure.Functions.Worker.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.Functions.Worker.Extensions.WebPush) from NuGet. 26 | 27 | ``` 28 | PM> Install-Package Lib.Net.Http.WebPush 29 | ``` 30 | 31 | ``` 32 | PM> Install-Package Lib.AspNetCore.WebPush 33 | ``` 34 | 35 | ``` 36 | PM> Install-Package Lib.Azure.WebJobs.Extensions.WebPush 37 | ``` 38 | 39 | ``` 40 | PM> Install-Package Lib.Azure.Functions.Worker.Extensions.WebPush 41 | ``` 42 | 43 | ## Documentation 44 | 45 | The documentation is available [here](https://tpeczek.github.io/Lib.Net.Http.WebPush/). 46 | 47 | ## Demos 48 | 49 | There are several demo projects available: 50 | - [Web Push Notifications in ASP.NET Core Web Application](https://github.com/tpeczek/Demo.AspNetCore.PushNotifications) 51 | - [Web Push Notifications in ASP.NET Core-powered Angular Application](https://github.com/tpeczek/Demo.AspNetCore.Angular.PushNotifications) 52 | - [Web Push Notifications in Azure Functions](https://github.com/tpeczek/Demo.Azure.Funtions.PushNotifications) 53 | 54 | ## Donating 55 | 56 | My blog and open source projects are result of my passion for software development, but they require a fair amount of my personal time. If you got value from any of the content I create, then I would appreciate your support by [sponsoring me](https://github.com/sponsors/tpeczek) (either monthly or one-time). 57 | 58 | ## Copyright and License 59 | 60 | Copyright © 2018 - 2025 Tomasz Pęczek 61 | 62 | Licensed under the [MIT License](https://github.com/tpeczek/Lib.Net.Http.WebPush/blob/master/LICENSE.md) -------------------------------------------------------------------------------- /benchmarks/Benchmark.Net.Http.WebPush/Benchmark.Net.Http.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /benchmarks/Benchmark.Net.Http.WebPush/Program.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Reports; 2 | using BenchmarkDotNet.Running; 3 | using System; 4 | 5 | namespace Benchmark.Net.Http.WebPush 6 | { 7 | class Program 8 | { 9 | static void Main(string[] args) 10 | { 11 | Summary webPushBenchmarksSummary = BenchmarkRunner.Run(); 12 | 13 | Console.ReadKey(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /benchmarks/Benchmark.Net.Http.WebPush/WebPushBenchmarks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Collections.Generic; 7 | using BenchmarkDotNet.Attributes; 8 | using Lib.Net.Http.WebPush; 9 | using Lib.Net.Http.WebPush.Authentication; 10 | using static Lib.Net.Http.WebPush.Authentication.VapidAuthentication; 11 | 12 | namespace Benchmark.Net.Http.WebPush 13 | { 14 | [MemoryDiagnoser] 15 | public class WebPushBenchmarks 16 | { 17 | #region Classes 18 | private class RequestPushMessageDeliveryVapidTokenCache : IVapidTokenCache 19 | { 20 | public string Get(string audience) 21 | { 22 | return "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTUxODMzMzkyNCwic3ViIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NjU1MDYvIn0.RP8t19y_3c6ncoE7iHOukEEKxIb8nBHdOaeY8xzLoMw62-GlWR5C1Rp8iG2rex9_pk_1LR4MJSAkMpDbhnZo5w"; 23 | } 24 | 25 | public void Put(string audience, DateTimeOffset expiration, string token) 26 | { } 27 | } 28 | 29 | private class RequestPushMessageDeliveryHttpMessageHandler : HttpMessageHandler 30 | { 31 | Task _createdResponseMessageTask = Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)); 32 | 33 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 34 | { 35 | return _createdResponseMessageTask; 36 | } 37 | } 38 | #endregion 39 | 40 | #region Fields 41 | private readonly PushMessage _message = new PushMessage("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse sit amet dolor tristique, tempus mi ultrices, vulputate erat. Nulla viverra mauris et ante pharetra accumsan. Praesent venenatis nibh vel aliquam pharetra. Suspendisse massa justo, mollis vitae venenatis eu, mattis sit amet ipsum. Etiam dolor lacus, vulputate id congue eu, mattis vel orci. Curabitur dignissim posuere vehicula. Quisque tristique tellus ligula, sit amet tincidunt sapien dignissim ut. Quisque pulvinar justo non turpis vehicula, hendrerit semper mi gravida. In vitae massa et erat commodo dignissim at id massa. Etiam non fringilla dolor, eu sagittis elit. Vestibulum finibus molestie viverra. Duis urna libero, pulvinar et mollis sed, volutpat sed orci. Sed dapibus vitae urna a volutpat. Phasellus ultricies eget quam commodo sagittis. Nunc sollicitudin ullamcorper faucibus. Vestibulum tempus molestie justo, at tristique urna finibus vel. Nam vitae eros gravida, tincidunt lorem quis, lacinia dui. Aliquam pretium metus nec risus scelerisque, eget auctor quam maximus. Vestibulum tempor metus egestas, maximus felis id, cursus diam. Sed non libero quis nibh scelerisque pellentesque vel nec nunc. Aliquam luctus ornare justo, at fermentum orci scelerisque at. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Etiam tempus finibus sem ac egestas. Fusce vitae justo ligula. Integer vel felis maximus odio commodo auctor eu quis nisl. Interdum et malesuada fames ac ante ipsum primis in faucibus. Suspendisse finibus mauris mattis luctus luctus. Integer imperdiet turpis vitae elit interdum sagittis. Aenean semper at sapien sit amet porttitor. Proin vel lacus vestibulum, consectetur urna sed, ultricies eros. Integer feugiat enim quis posuere ultricies. Quisque ac mattis diam. Nulla non blandit neque. Vivamus non elementum leo, eget vestibulum odio. Curabitur dignissim justo urna, quis imperdiet metus porttitor a. Nunc porta justo erat, vel malesuada mauris consectetur sed. Etiam fermentum dapibus mi, a ultrices ex euismod ut. Vivamus fringilla, sem ac pulvinar auctor, arcu mauris viverra erat, nec tempor orci nunc nec ipsum. Donec hendrerit eros aliquam finibus efficitur. Aliquam id sem eu urna volutpat consequat. Integer varius tellus eget sem cursus laoreet. Suspendisse potenti. Duis a dolor eros. Proin aliquet nisl dui, at faucibus odio porttitor a. Ut at fermentum purus, vel tincidunt massa. Aliquam ornare ornare augue ut ullamcorper. In cursus auctor purus, sodales suscipit enim sagittis vel. Etiam nec semper magna. Vivamus a mi congue, efficitur nisl vitae, sagittis urna. Vivamus ut ex a enim commodo feugiat. Nam dictum egestas neque eget tristique. Nam interdum mi non ornare rhoncus. Nam mi arcu, placerat sollicitudin ligula eu, efficitur hendrerit elit. Aliquam suscipit lacinia ante ac suscipit. Aliquam malesuada dolor at lorem mattis, ut feugiat mauris semper. Nulla tortor leo, porttitor eu ipsum vel, lobortis bibendum tortor. Quisque semper quam at cursus laoreet. Fusce in facilisis orci. Vivamus dapibus ac arcu ut condimentum. Morbi mollis a turpis nec sollicitudin. Aliquam fringilla massa elit, a mollis velit rhoncus eget. Aliquam erat volutpat. Vestibulum ante mauris, aliquet non purus ac, ornare pretium neque. Maecenas nec tortor justo. Curabitur ullamcorper placerat mauris vel scelerisque. Quisque maximus nunc sit amet aliquam pulvinar. Maecenas ullamcorper dictum efficitur. Proin auctor, sapien nec efficitur semper, dolor purus faucibus ipsum, fringilla consequat ipsum magna quis dui. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam erat volutpat. Suspendisse potenti. Maecenas orci odio, interdum ut lacus ac, sagittis molestie turpis. Donec pretium diam quis ex aliquam, quis rutrum ipsum egestas. Maecenas eu leo finibus, tincidunt leo sed, accumsan tellus. Sed aliquam placerat lectus, at tempor massa volutpat id. Integer a viverra ipsum. Etiam eu gravida risus. Maecenas turpis, porta vitae dignissim a, facilisis ac tortor. Nulla nec sed."); 42 | private readonly PushSubscription _subscription = new PushSubscription 43 | { 44 | Endpoint = "https://fcm.googleapis.com/fcm/send/fvi1URmMPCw:APA91bGqK_mH7LfRzY1wKeTj_kpealAK_HhRKpTNPSQ7dW8TQjR4qK4a7BcZcMRIHU9A6cH6iimqP7zyirmxjKd4vOKwos_itEI78bVF0LqCn5Xv99QkMxW_YZR7d2j993LapxJvzZ_V", 45 | Keys = new Dictionary 46 | { 47 | { "p256dh", "BP7M3CU6iBsNCJZAYk2akn2j236QjtxXZ6hMZP9H6XKTHFmjG1Al-fVW49Dz8Zlr_Qtqz30fv3yNrRelpj8Gvtg=" }, 48 | { "auth", "YE41bJSLtSq-hraJ17ryyA==" } 49 | } 50 | }; 51 | 52 | private VapidAuthentication _vapidAuthentication = new VapidAuthentication("BK5sn4jfa0Jqo9MhV01oyzK2FaEHm0KqkSCuUkKr53-9cr-vBE1a9TiiBaWy7hy0eOUF1jhZnwcd3vof4wnwSw0", "AJ2ho7or-6D4StPktpTO3l1ErjHGyxb0jzt9Y8lj67g") 53 | { 54 | Subject = "https://localhost:65506/" 55 | }; 56 | 57 | private PushServiceClient _pushClient = new PushServiceClient(new HttpClient(new RequestPushMessageDeliveryHttpMessageHandler())) 58 | { 59 | DefaultAuthentication = new VapidAuthentication("BK5sn4jfa0Jqo9MhV01oyzK2FaEHm0KqkSCuUkKr53-9cr-vBE1a9TiiBaWy7hy0eOUF1jhZnwcd3vof4wnwSw0", "AJ2ho7or-6D4StPktpTO3l1ErjHGyxb0jzt9Y8lj67g") 60 | { 61 | Subject = "https://localhost:65506/", 62 | TokenCache = new RequestPushMessageDeliveryVapidTokenCache() 63 | } 64 | }; 65 | #endregion 66 | 67 | #region Benchmarks 68 | [Benchmark] 69 | public void VapidAuthentication_GetWebPushSchemeHeadersValues() 70 | { 71 | WebPushSchemeHeadersValues webPushSchemeHeadersValues = _vapidAuthentication.GetWebPushSchemeHeadersValues("https://fcm.googleapis.com"); 72 | } 73 | 74 | [Benchmark] 75 | public void VapidAuthentication_GetVapidSchemeAuthenticationHeaderValueParameter() 76 | { 77 | string vapidSchemeAuthenticationHeaderValueParameter = _vapidAuthentication.GetVapidSchemeAuthenticationHeaderValueParameter("https://fcm.googleapis.com"); 78 | } 79 | 80 | [Benchmark] 81 | public Task PushServiceClient_RequestPushMessageDeliveryAsync() 82 | { 83 | return _pushClient.RequestPushMessageDeliveryAsync(_subscription, _message); 84 | } 85 | #endregion 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/DocFx.Net.Http.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore; 2 | using Microsoft.AspNetCore.Hosting; 3 | 4 | namespace DocFx.Net.Http.WebPush 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) => WebHost.CreateDefaultBuilder(args).UseStartup().Build().Run(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace DocFx.Net.Http.WebPush 6 | { 7 | public class Startup 8 | { 9 | public void ConfigureServices(IServiceCollection services) 10 | { } 11 | 12 | public void Configure(IApplicationBuilder app, IHostEnvironment env) 13 | { 14 | app.UseDefaultFiles() 15 | .UseStaticFiles(); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/articles/aspnetcore-integration.md: -------------------------------------------------------------------------------- 1 | # PushServiceClient extensions for ASP.NET Core 2 | 3 | The [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) extensions for ASP.NET Core provide an easy way to configure and create `PushServiceClient` instances in an app. 4 | 5 | ## Basic usage 6 | 7 | The `PushServiceClient` factory (under the hood it uses `IHttpClientFactory`) can be registered by calling the [`AddPushServiceClient`](../api/Microsoft.Extensions.DependencyInjection.PushServiceClientServiceCollectionExtensions.html#Microsoft_Extensions_DependencyInjection_PushServiceClientServiceCollectionExtensions_AddPushServiceClient_Microsoft_Extensions_DependencyInjection_IServiceCollection_) extension method on the `IServiceCollection`, inside the `Startup.ConfigureServices` method. During registration there is an option to provide default authentication and configuration for a `PushServiceClient`. 8 | 9 | ```cs 10 | services.AddPushServiceClient(options => 11 | { 12 | ... 13 | 14 | options.PublicKey = ""; 15 | options.PrivateKey = ""; 16 | }); 17 | ``` 18 | 19 | Once registered, code can accept a `PushServiceClient` anywhere services can be injected with dependency injection (DI). 20 | 21 | ```cs 22 | internal class PushNotificationsDequeuer : IHostedService 23 | { 24 | private readonly PushServiceClient _pushClient; 25 | 26 | public PushNotificationsDequeuer(PushServiceClient pushClient) 27 | { 28 | _pushClient = pushClient; 29 | } 30 | } 31 | ``` 32 | 33 | ## VAPID Tokens Caching 34 | 35 | There is also an option to enable *VAPID* tokens caching by calling the [`AddMemoryVapidTokenCache`](../api/Microsoft.Extensions.DependencyInjection.PushServiceClientServiceCollectionExtensions.html#Microsoft_Extensions_DependencyInjection_PushServiceClientServiceCollectionExtensions_AddMemoryVapidTokenCache_Microsoft_Extensions_DependencyInjection_IServiceCollection_) or [`AddDistributedVapidTokenCache`](../api/Microsoft.Extensions.DependencyInjection.PushServiceClientServiceCollectionExtensions.html#Microsoft_Extensions_DependencyInjection_PushServiceClientServiceCollectionExtensions_AddDistributedVapidTokenCache_Microsoft_Extensions_DependencyInjection_IServiceCollection_) mehod. 36 | 37 | ```cs 38 | services.AddMemoryCache(); 39 | services.AddMemoryVapidTokenCache(); 40 | services.AddPushServiceClient(options => 41 | { 42 | ... 43 | }); 44 | ``` 45 | 46 | Once a *VAPID* tokens cache is registered the factory will start using it automatically. -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/articles/azure-functions-integration.md: -------------------------------------------------------------------------------- 1 | # PushServiceClient bindings for Azure Functions 2 | 3 | The [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) extensions for Azure Functions supports input binding. 4 | 5 | ## Packages 6 | 7 | The [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) extensions for Azure Functions are provided in the [Lib.Azure.WebJobs.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.WebJobs.Extensions.WebPush) (in-process model) and [Lib.Azure.Functions.Worker.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.Functions.Worker.Extensions.WebPush) (isolated worker model) NuGet packages. 8 | 9 | ``` 10 | PM> Install-Package Lib.Azure.WebJobs.Extensions.WebPush 11 | ``` 12 | 13 | ``` 14 | PM> Install-Package Lib.Azure.Functions.Worker.Extensions.WebPush 15 | ``` 16 | 17 | ## Input 18 | 19 | The [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) input binding uses `HttpClientFactory` to retrieve [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) instance and passes it to the input parameter of the function. 20 | 21 | ### Input - language-specific examples 22 | 23 | #### Input - C# examples 24 | 25 | ##### Isolated Worker Model 26 | In [the isolated worker model functions](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide?tabs=windows), use the [`PushServiceInput`](../api/Lib.Azure.Functions.Worker.Extensions.WebPush.PushServiceInputAttribute.html) attribute. 27 | 28 | The attribute's constructor takes the application server public key and application server private key. For information about those settings and other properties that you can configure, see [Input - configuration](#input---configuration). 29 | 30 | ###### Azure Cosmos DB trigger, subscriptions from Azure Cosmos DB 31 | The following example shows a [C# function](https://docs.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) that broadcasts a notification to all known subscriptions. The function is triggered by a change in Azure Cosmos DB collection and retrieves subscriptions also from Azure Cosmos DB. 32 | 33 | ```cs 34 | ... 35 | 36 | namespace Demo.Azure.Functions.Worker.PushNotifications 37 | { 38 | public class SendNotificationFunction 39 | { 40 | private readonly ILogger _logger; 41 | 42 | public SendNotificationFunction(ILoggerFactory loggerFactory) 43 | { 44 | _logger = loggerFactory.CreateLogger(); 45 | } 46 | 47 | [Function("SendNotificationFunction")] 48 | public async Task Run([CosmosDBTrigger( 49 | databaseName: "PushNotifications", 50 | containerName: "Notifications", 51 | Connection = "CosmosDBConnection", 52 | LeaseContainerName = "NotificationsLeaseCollection", 53 | CreateLeaseContainerIfNotExists = true)] IReadOnlyList notifications, 54 | [CosmosDBInput( 55 | databaseName: "PushNotifications", 56 | containerName: "Subscriptions", 57 | Connection = "CosmosDBConnection")] CosmosClient cosmosClient, 58 | [PushServiceInput( 59 | PublicKeySetting = "ApplicationServerPublicKey", 60 | PrivateKeySetting = "ApplicationServerPrivateKey", 61 | SubjectSetting = "ApplicationServerSubject")] PushServiceClient pushServiceClient) 62 | { 63 | if (notifications != null) 64 | { 65 | Container subscriptionsContainer = cosmosClient.GetDatabase("PushNotifications").GetContainer("Subscriptions"); 66 | using (FeedIterator subscriptionsIterator = subscriptionsContainer.GetItemQueryIterator()) 67 | { 68 | while (subscriptionsIterator.HasMoreResults) 69 | { 70 | foreach (PushSubscription subscription in await subscriptionsIterator.ReadNextAsync()) 71 | { 72 | foreach (Notification notification in notifications) 73 | { 74 | // Fire-and-forget 75 | pushServiceClient.RequestPushMessageDeliveryAsync(subscription, new PushMessage(notification.Content) 76 | { 77 | Topic = notification.Topic, 78 | TimeToLive = notification.TimeToLive, 79 | Urgency = notification.Urgency 80 | }); 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | public class Notification 90 | { 91 | public string? Topic { get; set; } 92 | 93 | public string Content { get; set; } = String.Empty; 94 | 95 | public int? TimeToLive { get; set; } 96 | 97 | public PushMessageUrgency Urgency { get; set; } 98 | } 99 | } 100 | ``` 101 | 102 | ##### In-process Model 103 | In [C# class libraries](https://docs.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library), use the [`PushService`](../api/Lib.Azure.WebJobs.Extensions.WebPush.Bindings.PushServiceAttribute.html) attribute. 104 | 105 | The attribute's constructor takes the application server public key and application server private key. For information about those settings and other properties that you can configure, see [Input - configuration](#input---configuration). 106 | 107 | ###### Azure Cosmos DB trigger, subscriptions from Azure Cosmos DB 108 | The following example shows a [C# function](https://docs.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) that broadcasts a notification to all known subscriptions. The function is triggered by a change in Azure Cosmos DB collection and retrieves subscriptions also from Azure Cosmos DB. 109 | 110 | ```cs 111 | ... 112 | 113 | namespace Demo.Azure.Funtions.PushNotifications 114 | { 115 | public class Notification 116 | { 117 | public string Topic { get; set; } 118 | 119 | public string Content { get; set; } 120 | 121 | public int? TimeToLive { get; set; } 122 | 123 | public PushMessageUrgency Urgency { get; set; } 124 | } 125 | 126 | public static class SendNotificationFunction 127 | { 128 | [FunctionName("SendNotification")] 129 | public static async Task Run([CosmosDBTrigger( 130 | databaseName: "PushNotifications", 131 | containerName: "Notifications", 132 | Connection = "CosmosDBConnection", 133 | LeaseContainerName = "NotificationsLeaseCollection", 134 | CreateLeaseContainerIfNotExists = true)]IReadOnlyList notifications, 135 | [CosmosDB( 136 | databaseName: "PushNotifications", 137 | containerName: "Subscriptions", 138 | Connection = "CosmosDBConnection")]CosmosClient cosmosClient, 139 | [PushService( 140 | PublicKeySetting = "ApplicationServerPublicKey", 141 | PrivateKeySetting = "ApplicationServerPrivateKey", 142 | SubjectSetting = "ApplicationServerSubject")]PushServiceClient pushServiceClient) 143 | { 144 | if (notifications != null) 145 | { 146 | Container subscriptionsContainer = cosmosClient.GetDatabase("PushNotifications").GetContainer("Subscriptions"); 147 | using (FeedIterator subscriptionsIterator = subscriptionsContainer.GetItemQueryIterator()) 148 | { 149 | while (subscriptionsIterator.HasMoreResults) 150 | { 151 | foreach (PushSubscription subscription in await subscriptionsIterator.ReadNextAsync()) 152 | { 153 | foreach (Notification notification in notifications) 154 | { 155 | // Fire-and-forget 156 | pushServiceClient.RequestPushMessageDeliveryAsync(subscription, new PushMessage(notification.Content) 157 | { 158 | Topic = notification.Topic, 159 | TimeToLive = notification.TimeToLive, 160 | Urgency = notification.Urgency 161 | }); 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | ``` 171 | 172 | ### Input - configuration 173 | 174 | The following table explains the binding configuration properties that you set in the *function.json* file and the [`PushService`](../api/Lib.Azure.WebJobs.Extensions.WebPush.Bindings.PushServiceAttribute.html) attribute. 175 | 176 | |function.json property | Attribute property |Description| 177 | |---------|---------|----------------------| 178 | |**type** || Must be set to `pushService`. | 179 | |**direction** || Must be set to `in`. | 180 | |**publicKeySetting**|**PublicKeySetting** | The name of an app setting that contains the application server public key. | 181 | |**privateKeySetting**|**PrivateKeySetting** | The name of an app setting that contains the application server private key. | 182 | |**subjectSetting**|**SubjectSetting** | The name of an app setting that contains the contact information for the application server. | 183 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/articles/performance-considerations.md: -------------------------------------------------------------------------------- 1 | # Performance Considerations 2 | 3 | A typical application can be processing a very high number of *Push Messages*. Because of that it's important to consider performance best practices. 4 | 5 | ## Proper Instantiation 6 | 7 | The [`PushServiceClient`](../api/Lib.Net.Http.WebPush.PushServiceClient.html) class is internally holding an instance of `HttpClient` class. As one can read in documentation: 8 | 9 | > HttpClient is intended to be instantiated once and re-used throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads. 10 | 11 | Because of that (in order to avoid Improper Instantiation antipattern) a shared singleton instance of `PushServiceClient` should be created or a pool of reusable instances should be used. 12 | 13 | ## VAPID Tokens Caching 14 | 15 | Generating *VAPID* tokens requires expensive cryptography. The structure of tokens allows for them to be cached per *Audience* (which means by *Push Service*) and *Application Server Keys* pair (for the token expiration period). This library provides such possibility through [`IVapidTokenCache`](../api/Lib.Net.Http.WebPush.Authentication.IVapidTokenCache.html). If an implementation of this interface will be provided to [`VapidAuthentication`](../api/Lib.Net.Http.WebPush.Authentication.VapidAuthentication.html) instance, it will result in tokens being cached. 16 | 17 | Below is a sample implementation which uses [ASP.NET Core in-memory caching](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory). 18 | 19 | ```cs 20 | public class MemoryVapidTokenCache : IVapidTokenCache 21 | { 22 | private readonly IMemoryCache _memoryCache; 23 | 24 | public MemoryVapidTokenCache(IMemoryCache memoryCache) 25 | { 26 | _memoryCache = memoryCache; 27 | } 28 | 29 | public string Get(string audience) 30 | { 31 | if (!_memoryCache.TryGetValue(audience, out string token)) 32 | { 33 | token = null; 34 | } 35 | 36 | return token; 37 | } 38 | 39 | public void Put(string audience, DateTimeOffset expiration, string token) 40 | { 41 | _memoryCache.Set(audience, token, expiration); 42 | } 43 | } 44 | ``` 45 | 46 | The usage of *Audience* as cache key means that intended context of this interface is a single *Application Server Keys* pair. If application is handling multiple *Application Server Keys* pairs it should provide a separate implementation for every pair and make sure those doesn't clash. -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/docfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": [ 3 | { 4 | "src": [ 5 | { 6 | "files": [ 7 | "src/Lib.Net.Http.WebPush/Lib.Net.Http.WebPush.csproj", 8 | "src/Lib.AspNetCore.WebPush/Lib.AspNetCore.WebPush.csproj", 9 | "src/Lib.Azure.WebJobs.Extensions.WebPush/Lib.Azure.WebJobs.Extensions.WebPush.csproj", 10 | "src/Lib.Azure.Functions.Worker.Extensions.WebPush/Lib.Azure.Functions.Worker.Extensions.WebPush.csproj" 11 | ], 12 | "exclude": [ "**/bin/**", "**/obj/**" ], 13 | "src": "../.." 14 | } 15 | ], 16 | "dest": "api", 17 | "properties": { 18 | "TargetFramework": "net6.0" 19 | } 20 | } 21 | ], 22 | "build": { 23 | "content": [ 24 | { 25 | "files": [ 26 | "api/*.yml", 27 | "toc.md", 28 | "index.md", 29 | "articles/performance-considerations.md", 30 | "articles/aspnetcore-integration.md", 31 | "articles/azure-functions-integration.md" 32 | ] 33 | } 34 | ], 35 | "resource": [ 36 | { 37 | "files": [ 38 | "resources/svg/logo.svg", 39 | "resources/ico/favicon.ico" 40 | ] 41 | } 42 | ], 43 | "dest": "wwwroot", 44 | "globalMetadata": { 45 | "_appTitle": "Lib.Net.Http.WebPush", 46 | "_appFooter": "Copyright © 2018 - 2025 Tomasz Pęczek", 47 | "_appLogoPath": "resources/svg/logo.svg", 48 | "_appFaviconPath": "resources/ico/favicon.ico", 49 | "_disableBreadcrumb": true, 50 | "_disableAffix": true, 51 | "_disableContribution": true 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/index.md: -------------------------------------------------------------------------------- 1 | # Lib.Net.Http.WebPush 2 | 3 | Lib.Net.Http.WebPush is a library which provides a [*Web Push Protocol*](https://tools.ietf.org/html/rfc8030) based client for *Push Service*. It provides support for [Voluntary Application Server Identification (*VAPID*) for Web Push](https://tools.ietf.org/html/rfc8292) and [Message Encryption for Web Push](https://tools.ietf.org/html/rfc8291). 4 | 5 | Lib.AspNetCore.WebPush is a library which provides ASP.NET Core extensions for Web Push Protocol based client for Push Service. 6 | 7 | Lib.Azure.WebJobs.Extensions.WebPush is a library which provides [Azure Functions](https://functions.azure.com/) in-process model and [Azure WebJobs](https://docs.microsoft.com/en-us/azure/app-service/web-sites-create-web-jobs) binding extensions for Web Push Protocol based client for Push Service. 8 | 9 | Lib.Azure.Functions.Worker.Extensions.WebPush is a library which provides [Azure Functions](https://functions.azure.com/) isolated worker model extensions for Web Push Protocol based client for Push Service. 10 | 11 | ## Installation 12 | 13 | You can install [Lib.Net.Http.WebPush](https://www.nuget.org/packages/Lib.Net.Http.WebPush), [Lib.AspNetCore.WebPush](https://www.nuget.org/packages/Lib.AspNetCore.WebPush), [Lib.Azure.WebJobs.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.WebJobs.Extensions.WebPush), and [Lib.Azure.Functions.Worker.Extensions.WebPush](https://www.nuget.org/packages/Lib.Azure.Functions.Worker.Extensions.WebPush) from NuGet. 14 | 15 | ``` 16 | PM> Install-Package Lib.Net.Http.WebPush 17 | ``` 18 | 19 | ``` 20 | PM> Install-Package Lib.AspNetCore.WebPush 21 | ``` 22 | 23 | ``` 24 | PM> Install-Package Lib.Azure.WebJobs.Extensions.WebPush 25 | ``` 26 | 27 | ``` 28 | PM> Install-Package Lib.Azure.Functions.Worker.Extensions.WebPush 29 | ``` 30 | 31 | ## Demos 32 | 33 | There are several demo projects available: 34 | - [Web Push Notifications in ASP.NET Core Web Application](https://github.com/tpeczek/Demo.AspNetCore.PushNotifications) 35 | - [Web Push Notifications in Azure Functions](https://github.com/tpeczek/Demo.Azure.Funtions.PushNotifications) 36 | 37 | ## Additional Resources 38 | 39 | There is a "Push Notifications and ASP.NET Core" series which provides a lot of information about *Push API*, *Web Push Protocol* and internals of this library: 40 | 41 | - [Push Notifications and ASP.NET Core - Part 1 (Push API)](https://www.tpeczek.com/2017/12/push-notifications-and-aspnet-core-part.html) 42 | - [Push Notifications and ASP.NET Core - Part 2 (Requesting Delivery)](https://www.tpeczek.com/2018/01/push-notifications-and-aspnet-core-part.html) 43 | - [Push Notifications and ASP.NET Core - Part 3 (Replacing Messages & Urgency)](https://www.tpeczek.com/2018/01/push-notifications-and-aspnet-core-part_18.html) 44 | - [Push Notifications and ASP.NET Core - Part 5 (Special Cases)](https://www.tpeczek.com/2019/02/push-notifications-and-aspnet-core-part.html) 45 | - [Scaling Web Push Notifications with Azure Functions](https://www.tpeczek.com/2019/02/scaling-web-push-notifications-with.html) 46 | 47 | ## Donating 48 | 49 | My blog and open source projects are result of my passion for software development, but they require a fair amount of my personal time. If you got value from any of the content I create, then I would appreciate your support by sponsoring me. 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/resources/ico/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpeczek/Lib.Net.Http.WebPush/2d84bc3ef915223491b5417c205be596064ec4a8/docs/DocFx.Net.Http.WebPush/resources/ico/favicon.ico -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/resources/svg/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/DocFx.Net.Http.WebPush/toc.md: -------------------------------------------------------------------------------- 1 | # [Introduction](index.md) 2 | 3 | # [Performance Considerations](articles/performance-considerations.md) 4 | 5 | # [ASP.NET Core Integration](articles/aspnetcore-integration.md) 6 | 7 | # [Azure Functions Integration](articles/azure-functions-integration.md) 8 | 9 | # [API Reference](api/Lib.Net.Http.WebPush.html) -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/Caching/DistributedVapidTokenCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Caching.Distributed; 3 | using Lib.Net.Http.WebPush.Authentication; 4 | 5 | namespace Lib.AspNetCore.WebPush.Caching 6 | { 7 | internal class DistributedVapidTokenCache : IVapidTokenCache 8 | { 9 | private readonly IDistributedCache _distributedCache; 10 | 11 | public DistributedVapidTokenCache(IDistributedCache distributedCache) 12 | { 13 | _distributedCache = distributedCache; 14 | } 15 | 16 | public string Get(string audience) 17 | { 18 | return _distributedCache.GetString(audience); 19 | } 20 | 21 | public void Put(string audience, DateTimeOffset expiration, string token) 22 | { 23 | DistributedCacheEntryOptions cacheEntryOptions = new DistributedCacheEntryOptions() 24 | .SetAbsoluteExpiration(expiration); 25 | 26 | _distributedCache.SetString(audience, token, cacheEntryOptions); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/Caching/MemoryVapidTokenCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Lib.Net.Http.WebPush.Authentication; 4 | 5 | namespace Lib.AspNetCore.WebPush.Caching 6 | { 7 | internal class MemoryVapidTokenCache : IVapidTokenCache 8 | { 9 | private readonly IMemoryCache _memoryCache; 10 | 11 | public MemoryVapidTokenCache(IMemoryCache memoryCache) 12 | { 13 | _memoryCache = memoryCache; 14 | } 15 | 16 | public string Get(string audience) 17 | { 18 | if (!_memoryCache.TryGetValue(audience, out string token)) 19 | { 20 | token = null; 21 | } 22 | 23 | return token; 24 | } 25 | 26 | public void Put(string audience, DateTimeOffset expiration, string token) 27 | { 28 | _memoryCache.Set(audience, token, expiration); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/Lib.AspNetCore.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | This package contains ASP.NET Core extensions for Web Push Protocol based client for Push Service. 4 | AspNetCore WebPush 5 | Copyright © 2019 - 2025 Tomasz Pęczek 6 | 2.2.2 7 | Tomasz Pęczek 8 | netcoreapp3.1;net5.0;net6.0;net461 9 | Lib.AspNetCore.WebPush 10 | Lib.AspNetCore.WebPush 11 | Lib.AspNetCore.WebPush 12 | aspnetcore;webpush 13 | https://github.com/tpeczek/Lib.Net.Http.WebPush 14 | MIT 15 | README.md 16 | git 17 | git://github.com/tpeczek/Lib.Net.Http.WebPush 18 | true 19 | true 20 | true 21 | true 22 | true 23 | true 24 | latest 25 | true 26 | Lib.AspNetCore.WebPush.snk 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/Lib.AspNetCore.WebPush.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpeczek/Lib.Net.Http.WebPush/2d84bc3ef915223491b5417c205be596064ec4a8/src/Lib.AspNetCore.WebPush/Lib.AspNetCore.WebPush.snk -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/PushServiceClientOptions.cs: -------------------------------------------------------------------------------- 1 | using Lib.Net.Http.WebPush; 2 | using Lib.Net.Http.WebPush.Authentication; 3 | 4 | namespace Lib.AspNetCore.WebPush 5 | { 6 | /// 7 | /// The options for 8 | /// 9 | public class PushServiceClientOptions 10 | { 11 | #region Properties 12 | /// 13 | /// Gets or sets the contact information for the application server. 14 | /// 15 | public string Subject { get; set; } 16 | 17 | /// 18 | /// Gets or sets the Application Server Public Key. 19 | /// 20 | public string PublicKey { get; set; } 21 | 22 | /// 23 | /// Gets or sets the Application Server Private Key. 24 | /// 25 | public string PrivateKey { get; set; } 26 | 27 | /// 28 | /// Gets or sets the time after which the authentication token expires (in seconds). 29 | /// 30 | public int? Expiration { get; set; } 31 | 32 | /// 33 | /// Gets or sets the default to be used. 34 | /// 35 | public VapidAuthenticationScheme DefaultAuthenticationScheme { get; set; } = VapidAuthenticationScheme.Vapid; 36 | 37 | /// 38 | /// Gets or sets the value indicating if client should automatically attempt to retry in case of 429 Too Many Requests. 39 | /// 40 | public bool AutoRetryAfter { get; set; } = true; 41 | 42 | /// 43 | /// Gets or sets the value indicating the maximum number of automatic attempts to retry in case of 429 Too Many Requests (<= 0 means unlimited). 44 | /// 45 | public int MaxRetriesAfter { get; set; } = 0; 46 | 47 | /// 48 | /// Gets or sets the default time (in seconds) for which the message should be retained by push service. It will be used when is not set. 49 | /// 50 | public int? DefaultTimeToLive { get; set; } 51 | #endregion 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Lib.AspNetCore.WebPush/PushServiceClientServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.Extensions.Caching.Memory; 5 | using Microsoft.Extensions.Caching.Distributed; 6 | using Lib.Net.Http.WebPush; 7 | using Lib.AspNetCore.WebPush; 8 | using Lib.Net.Http.WebPush.Authentication; 9 | using Lib.AspNetCore.WebPush.Caching; 10 | 11 | namespace Microsoft.Extensions.DependencyInjection 12 | { 13 | /// 14 | /// Extensions methods to configure an for . 15 | /// 16 | public static class PushServiceClientServiceCollectionExtensions 17 | { 18 | private const string HTTP_CLIENT_NAME = "Lib.AspNetCore.WebPush"; 19 | 20 | private class VapidAuthenticationProvider : IDisposable 21 | { 22 | public VapidAuthentication VapidAuthentication { get; } 23 | 24 | public VapidAuthenticationProvider(VapidAuthentication vapidAuthentication) 25 | { 26 | VapidAuthentication = vapidAuthentication; 27 | } 28 | 29 | public void Dispose() 30 | { 31 | VapidAuthentication?.Dispose(); 32 | } 33 | } 34 | 35 | /// 36 | /// Adds the based implementation of . 37 | /// 38 | /// The . 39 | /// The . 40 | public static IServiceCollection AddMemoryVapidTokenCache(this IServiceCollection services) 41 | { 42 | return services.AddSingleton(); 43 | } 44 | 45 | /// 46 | /// Adds the based implementation of . 47 | /// 48 | /// The . 49 | /// The . 50 | public static IServiceCollection AddDistributedVapidTokenCache(this IServiceCollection services) 51 | { 52 | return services.AddSingleton(); 53 | } 54 | 55 | /// 56 | /// Adds the and related services to the . 57 | /// 58 | /// The . 59 | /// The . 60 | public static IServiceCollection AddPushServiceClient(this IServiceCollection services) 61 | { 62 | if (services == null) 63 | { 64 | throw new ArgumentNullException(nameof(services)); 65 | } 66 | 67 | services.AddHttpClient(HTTP_CLIENT_NAME); 68 | 69 | services.AddSingleton(serviceProvider => 70 | { 71 | VapidAuthentication vapidAuthentication = null; 72 | 73 | IOptions options = serviceProvider.GetRequiredService>(); 74 | if (options.Value != null) 75 | { 76 | PushServiceClientOptions optionsValue = options.Value; 77 | 78 | if (!String.IsNullOrWhiteSpace(optionsValue.PrivateKey) && !String.IsNullOrWhiteSpace(optionsValue.PublicKey)) 79 | { 80 | vapidAuthentication = new VapidAuthentication(optionsValue.PublicKey, optionsValue.PrivateKey) 81 | { 82 | Subject = optionsValue.Subject 83 | }; 84 | 85 | if (optionsValue.Expiration.HasValue) 86 | { 87 | vapidAuthentication.Expiration = optionsValue.Expiration.Value; 88 | } 89 | 90 | vapidAuthentication.TokenCache = serviceProvider.GetService(); 91 | } 92 | } 93 | 94 | return new VapidAuthenticationProvider(vapidAuthentication); 95 | }); 96 | 97 | services.AddTransient(serviceProvider => 98 | { 99 | IHttpClientFactory clientFactory = serviceProvider.GetRequiredService(); 100 | IOptions options = serviceProvider.GetRequiredService>(); 101 | 102 | PushServiceClient pushServiceClient = new PushServiceClient(clientFactory.CreateClient(HTTP_CLIENT_NAME)); 103 | if (options.Value != null) 104 | { 105 | PushServiceClientOptions optionsValue = options.Value; 106 | 107 | VapidAuthenticationProvider vapidAuthenticationProvider = serviceProvider.GetRequiredService(); 108 | if (vapidAuthenticationProvider.VapidAuthentication != null) 109 | { 110 | pushServiceClient.DefaultAuthentication = vapidAuthenticationProvider.VapidAuthentication; 111 | } 112 | 113 | pushServiceClient.DefaultAuthenticationScheme = optionsValue.DefaultAuthenticationScheme; 114 | 115 | if (optionsValue.DefaultTimeToLive.HasValue) 116 | { 117 | pushServiceClient.DefaultTimeToLive = optionsValue.DefaultTimeToLive.Value; 118 | } 119 | 120 | pushServiceClient.AutoRetryAfter = optionsValue.AutoRetryAfter; 121 | pushServiceClient.MaxRetriesAfter = optionsValue.MaxRetriesAfter; 122 | } 123 | 124 | return pushServiceClient; 125 | }); 126 | 127 | return services; 128 | } 129 | 130 | /// 131 | /// Adds the and related services to the . 132 | /// 133 | /// The . 134 | /// Used to configure the . 135 | /// The . 136 | public static IServiceCollection AddPushServiceClient(this IServiceCollection services, Action configureOptions) 137 | { 138 | if (services == null) 139 | { 140 | throw new ArgumentNullException(nameof(services)); 141 | } 142 | 143 | if (configureOptions == null) 144 | { 145 | throw new ArgumentNullException(nameof(configureOptions)); 146 | } 147 | 148 | services.AddPushServiceClient(); 149 | services.Configure(configureOptions); 150 | 151 | return services; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 2 | { 3 | internal static class Constants 4 | { 5 | internal const string PUSH_SERVICE_EXTENSION_NAME = "PushService"; 6 | 7 | internal const string JSON_CONTENT_TYPE = "application/json"; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/FunctionsWorkerApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.Functions.Worker; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 6 | { 7 | internal static class FunctionsWorkerApplicationBuilderExtensions 8 | { 9 | public static IFunctionsWorkerApplicationBuilder ConfigurePushServiceExtension(this IFunctionsWorkerApplicationBuilder builder) 10 | { 11 | ArgumentNullException.ThrowIfNull(builder, nameof(builder)); 12 | 13 | builder.Services.AddHttpClient(); 14 | 15 | return builder; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/Lib.Azure.Functions.Worker.Extensions.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | This package contains Azure Functions isolated worker model extensions for Web Push Protocol based client for Push Service. 4 | Azure AzureFunctions IsolatedWorker WebPush 5 | Copyright © 2024 - 2025 Tomasz Pęczek 6 | 1.0.1 7 | Tomasz Pęczek 8 | net6.0 9 | Lib.Azure.Functions.Worker.Extensions.WebPush 10 | Lib.Azure.Functions.Worker.Extensions.WebPush 11 | Lib.Azure.Functions.Worker.Extensions.WebPush 12 | azure;azurefunctions;isolatedworker;webpush 13 | https://github.com/tpeczek/Lib.Net.Http.WebPush 14 | MIT 15 | README.md 16 | git 17 | git://github.com/tpeczek/Lib.Net.Http.WebPush 18 | true 19 | true 20 | true 21 | true 22 | true 23 | true 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | [assembly: Microsoft.Azure.Functions.Worker.Extensions.Abstractions.ExtensionInformation("Lib.Azure.WebJobs.Extensions.WebPush", "1.5.0")] 2 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/PushServiceConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | using Microsoft.Azure.Functions.Worker.Core; 5 | using Microsoft.Azure.Functions.Worker.Converters; 6 | using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; 7 | using Lib.Net.Http.WebPush; 8 | using Lib.Net.Http.WebPush.Authentication; 9 | 10 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 11 | { 12 | [SupportsDeferredBinding] 13 | [SupportedTargetType(typeof(PushServiceClient))] 14 | internal class PushServiceConverter : IInputConverter 15 | { 16 | private static readonly Type TYPE_OF_PUSH_SERVICE_CLIENT = typeof(PushServiceClient); 17 | 18 | private readonly IHttpClientFactory _httpClientFactory; 19 | 20 | public PushServiceConverter(IHttpClientFactory httpClientFactory) 21 | { 22 | _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); 23 | } 24 | 25 | public ValueTask ConvertAsync(ConverterContext context) 26 | { 27 | try 28 | { 29 | if (!CanConvert(context)) 30 | { 31 | return new(ConversionResult.Unhandled()); 32 | } 33 | 34 | var modelBindingData = context?.Source as ModelBindingData; 35 | 36 | PushServiceClient pushServiceClient = CreatePushServiceClient(modelBindingData); 37 | 38 | return new(ConversionResult.Success(pushServiceClient)); 39 | } 40 | catch (Exception ex) 41 | { 42 | return new(ConversionResult.Failed(ex)); 43 | } 44 | } 45 | 46 | private static bool CanConvert(ConverterContext context) 47 | { 48 | ArgumentNullException.ThrowIfNull(context, nameof(context)); 49 | 50 | if (context.TargetType != TYPE_OF_PUSH_SERVICE_CLIENT) 51 | { 52 | return false; 53 | } 54 | 55 | if (context.Source is not ModelBindingData bindingData) 56 | { 57 | return false; 58 | } 59 | 60 | if (bindingData.Source is not Constants.PUSH_SERVICE_EXTENSION_NAME) 61 | { 62 | throw new InvalidOperationException($"Unexpected binding source '{bindingData.Source}'. Only '{Constants.PUSH_SERVICE_EXTENSION_NAME}' is supported."); 63 | } 64 | 65 | if (bindingData.ContentType is not Constants.JSON_CONTENT_TYPE) 66 | { 67 | throw new InvalidOperationException($"Unexpected content-type '{bindingData.ContentType}'. Only '{Constants.JSON_CONTENT_TYPE}' is supported."); 68 | } 69 | 70 | return true; 71 | } 72 | 73 | private PushServiceClient CreatePushServiceClient(ModelBindingData bindingData) 74 | { 75 | var pushServiceModelBindingDataContent = bindingData.Content.ToObjectFromJson(); 76 | 77 | PushServiceClient pushServiceClient = new PushServiceClient(_httpClientFactory.CreateClient()) 78 | { 79 | DefaultAuthentication = new VapidAuthentication(pushServiceModelBindingDataContent.PublicKey, pushServiceModelBindingDataContent.PrivateKey) 80 | { 81 | Subject = pushServiceModelBindingDataContent.Subject 82 | }, 83 | AutoRetryAfter = pushServiceModelBindingDataContent.AutoRetryAfter, 84 | MaxRetriesAfter = pushServiceModelBindingDataContent.MaxRetriesAfter 85 | }; 86 | 87 | if (pushServiceModelBindingDataContent.DefaultTimeToLive.HasValue) 88 | { 89 | pushServiceClient.DefaultTimeToLive = pushServiceModelBindingDataContent.DefaultTimeToLive.Value; 90 | } 91 | 92 | return pushServiceClient; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/PushServiceExtensionStartup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.Functions.Worker; 3 | using Microsoft.Azure.Functions.Worker.Core; 4 | 5 | [assembly: WorkerExtensionStartup(typeof(Lib.Azure.Functions.Worker.Extensions.WebPush.PushServiceExtensionStartup))] 6 | 7 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 8 | { 9 | /// 10 | /// Class providing a worker extension startup implementation. 11 | /// 12 | public class PushServiceExtensionStartup : WorkerExtensionStartup 13 | { 14 | /// 15 | /// Performs the startup configuration action for Push Service extension. 16 | /// 17 | /// The that can be used to configure the worker extension. 18 | public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder) 19 | { 20 | ArgumentNullException.ThrowIfNull(applicationBuilder, nameof(applicationBuilder)); 21 | 22 | applicationBuilder.ConfigurePushServiceExtension(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/PushServiceInputAttribute.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Functions.Worker.Converters; 2 | using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; 3 | 4 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 5 | { 6 | /// 7 | /// Attribute used to configure a parameter as the input target for the PushService binding. 8 | /// 9 | /// 10 | /// The method parameter type can be one of the following: 11 | /// 12 | /// 13 | /// 14 | /// 15 | [InputConverter(typeof(PushServiceConverter))] 16 | [ConverterFallbackBehavior(ConverterFallbackBehavior.Default)] 17 | public sealed class PushServiceInputAttribute : InputBindingAttribute 18 | { 19 | /// 20 | /// The application server public key. 21 | /// 22 | public string PublicKeySetting { get; set; } 23 | 24 | /// 25 | /// The application server private key. 26 | /// 27 | public string PrivateKeySetting { get; set; } 28 | 29 | /// 30 | /// The contact information for the application server. 31 | /// 32 | public string SubjectSetting { get; set; } 33 | 34 | /// 35 | /// The value indicating if client should automatically attempt to retry in case of 429 Too Many Requests. 36 | /// 37 | public bool AutoRetryAfter { get; set; } = true; 38 | 39 | /// 40 | /// The value indicating the maximum number of automatic attempts to retry in case of 429 Too Many Requests (<= 0 means unlimited). 41 | /// 42 | public int MaxRetriesAfter { get; set; } = 0; 43 | 44 | /// 45 | /// The default time (in seconds) for which the message should be retained by push service. It will be used when is not set. 46 | /// 47 | public int? DefaultTimeToLive { get; set; } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Lib.Azure.Functions.Worker.Extensions.WebPush/PushServiceModelBindingDataContent.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Azure.Functions.Worker.Extensions.WebPush 2 | { 3 | internal class PushServiceModelBindingDataContent 4 | { 5 | public string PublicKey { get; set; } 6 | 7 | public string PrivateKey { get; set; } 8 | 9 | public string Subject { get; set; } 10 | 11 | public bool AutoRetryAfter { get; set; } 12 | 13 | public int MaxRetriesAfter { get; set; } 14 | 15 | public int? DefaultTimeToLive { get; set; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Bindings/PushServiceAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.WebJobs.Description; 3 | using Lib.Net.Http.WebPush; 4 | 5 | namespace Lib.Azure.WebJobs.Extensions.WebPush.Bindings 6 | { 7 | /// 8 | /// Attribute used to bind to a PushService. 9 | /// 10 | /// 11 | /// The method parameter type can be one of the following: 12 | /// 13 | /// 14 | /// 15 | /// 16 | [Binding] 17 | [AttributeUsage(AttributeTargets.Parameter)] 18 | public sealed class PushServiceAttribute : Attribute 19 | { 20 | /// 21 | /// The application server public key. 22 | /// 23 | [AppSetting] 24 | public string PublicKeySetting { get; set; } 25 | 26 | /// 27 | /// The application server private key. 28 | /// 29 | [AppSetting] 30 | public string PrivateKeySetting { get; set; } 31 | 32 | /// 33 | /// The contact information for the application server. 34 | /// 35 | [AppSetting] 36 | public string SubjectSetting { get; set; } 37 | 38 | /// 39 | /// The value indicating if client should automatically attempt to retry in case of 429 Too Many Requests. 40 | /// 41 | public bool AutoRetryAfter { get; set; } = true; 42 | 43 | /// 44 | /// The value indicating the maximum number of automatic attempts to retry in case of 429 Too Many Requests (<= 0 means unlimited). 45 | /// 46 | public int MaxRetriesAfter { get; set; } = 0; 47 | 48 | /// 49 | /// The default time (in seconds) for which the message should be retained by push service. It will be used when is not set. 50 | /// 51 | public int? DefaultTimeToLive { get; set; } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Bindings/PushServiceClientConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using Microsoft.Azure.WebJobs; 4 | using Lib.Net.Http.WebPush; 5 | using Lib.Net.Http.WebPush.Authentication; 6 | 7 | namespace Lib.Azure.WebJobs.Extensions.WebPush.Bindings 8 | { 9 | internal class PushServiceClientConverter : IConverter 10 | { 11 | private readonly PushServiceOptions _options; 12 | private readonly IHttpClientFactory _httpClientFactory; 13 | 14 | public PushServiceClientConverter(PushServiceOptions options, IHttpClientFactory httpClientFactory) 15 | { 16 | _options = options; 17 | _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); 18 | } 19 | 20 | public PushServiceClient Convert(PushServiceAttribute attribute) 21 | { 22 | PushServiceClient pushServiceClient = new PushServiceClient(_httpClientFactory.CreateClient()) 23 | { 24 | DefaultAuthentication = new VapidAuthentication(ResolveAuthenticationProperty(attribute.PublicKeySetting, _options?.PublicKey), ResolveAuthenticationProperty(attribute.PrivateKeySetting, _options?.PrivateKey)) 25 | { 26 | Subject = ResolveAuthenticationProperty(attribute.SubjectSetting, _options?.Subject) 27 | }, 28 | AutoRetryAfter = attribute.AutoRetryAfter, 29 | MaxRetriesAfter = attribute.MaxRetriesAfter 30 | }; 31 | 32 | if (attribute.DefaultTimeToLive.HasValue) 33 | { 34 | pushServiceClient.DefaultTimeToLive = attribute.DefaultTimeToLive.Value; 35 | } 36 | 37 | return pushServiceClient; 38 | } 39 | 40 | private static string ResolveAuthenticationProperty(string attributeValue, string optionsValue) 41 | { 42 | if (!String.IsNullOrEmpty(attributeValue)) 43 | { 44 | return attributeValue; 45 | } 46 | 47 | return optionsValue; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Bindings/PushServiceParameterBindingDataContent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Lib.Azure.WebJobs.Extensions.WebPush.Bindings 4 | { 5 | internal class PushServiceParameterBindingDataContent 6 | { 7 | public string PublicKey { get; set; } 8 | 9 | public string PrivateKey { get; set; } 10 | 11 | public string Subject { get; set; } 12 | 13 | public bool AutoRetryAfter { get; set; } 14 | 15 | public int MaxRetriesAfter { get; set; } 16 | 17 | public int? DefaultTimeToLive { get; set; } 18 | 19 | public PushServiceParameterBindingDataContent(PushServiceAttribute attribute, PushServiceOptions options) 20 | { 21 | PublicKey = !String.IsNullOrEmpty(attribute.PublicKeySetting) ? attribute.PublicKeySetting : options?.PublicKey; 22 | PrivateKey = !String.IsNullOrEmpty(attribute.PrivateKeySetting) ? attribute.PrivateKeySetting : options?.PrivateKey; 23 | Subject = !String.IsNullOrEmpty(attribute.SubjectSetting) ? attribute.SubjectSetting : options?.Subject; 24 | AutoRetryAfter = attribute.AutoRetryAfter; 25 | MaxRetriesAfter = attribute.MaxRetriesAfter; 26 | DefaultTimeToLive = attribute.DefaultTimeToLive; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Config/PushServiceExtensionConfigProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using Microsoft.Extensions.Options; 4 | using Microsoft.Azure.WebJobs; 5 | using Microsoft.Azure.WebJobs.Description; 6 | using Microsoft.Azure.WebJobs.Host.Config; 7 | using Lib.Azure.WebJobs.Extensions.WebPush.Bindings; 8 | using Lib.Net.Http.WebPush; 9 | 10 | namespace Lib.Azure.WebJobs.Extensions.WebPush.Config 11 | { 12 | [Extension(Constants.PushServiceExtensionName)] 13 | internal class PushServiceExtensionConfigProvider : IExtensionConfigProvider 14 | { 15 | private readonly PushServiceOptions _options; 16 | private readonly IHttpClientFactory _httpClientFactory; 17 | 18 | public PushServiceExtensionConfigProvider(IOptions options, IHttpClientFactory httpClientFactory) 19 | { 20 | _options = options.Value; 21 | _httpClientFactory = httpClientFactory; 22 | } 23 | 24 | public void Initialize(ExtensionConfigContext context) 25 | { 26 | if (context == null) 27 | { 28 | throw new ArgumentNullException(nameof(context)); 29 | } 30 | 31 | //PushServiceClient Bindings 32 | var bindingAttributeBindingRule = context.AddBindingRule(); 33 | bindingAttributeBindingRule.AddValidator(ValidateVapidAuthentication); 34 | 35 | bindingAttributeBindingRule.BindToInput(typeof(PushServiceClientConverter), _options, _httpClientFactory); 36 | bindingAttributeBindingRule.BindToInput(CreateParameterBindingData); 37 | } 38 | 39 | private void ValidateVapidAuthentication(PushServiceAttribute attribute, Type paramType) 40 | { 41 | if (String.IsNullOrEmpty(_options.PublicKey) && String.IsNullOrEmpty(attribute.PublicKeySetting)) 42 | { 43 | string attributeProperty = $"{nameof(PushServiceAttribute)}.{nameof(PushServiceAttribute.PublicKeySetting)}"; 44 | string optionsProperty = $"{nameof(PushServiceOptions)}.{nameof(PushServiceOptions.PublicKey)}"; 45 | throw new InvalidOperationException($"The application server public key must be set either via the {attributeProperty} property or via {optionsProperty}."); 46 | } 47 | 48 | if (String.IsNullOrEmpty(_options.PrivateKey) && String.IsNullOrEmpty(attribute.PrivateKeySetting)) 49 | { 50 | string attributeProperty = $"{nameof(PushServiceAttribute)}.{nameof(PushServiceAttribute.PrivateKeySetting)}"; 51 | string optionsProperty = $"{nameof(PushServiceOptions)}.{nameof(PushServiceOptions.PrivateKey)}"; 52 | throw new InvalidOperationException($"The application server private key must be set either via the {attributeProperty} property or via {optionsProperty}."); 53 | } 54 | } 55 | 56 | internal ParameterBindingData CreateParameterBindingData(PushServiceAttribute attribute) 57 | { 58 | var pushServiceParameterBindingData = new PushServiceParameterBindingDataContent(attribute, _options); 59 | var pushServiceParameterBinaryData = new BinaryData(pushServiceParameterBindingData); 60 | var parameterBindingData = new ParameterBindingData("1.0", Constants.PushServiceExtensionName, pushServiceParameterBinaryData, "application/json"); 61 | 62 | return parameterBindingData; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Azure.WebJobs.Extensions.WebPush 2 | { 3 | internal static class Constants 4 | { 5 | internal const string PushServiceExtensionName = "PushService"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Lib.Azure.WebJobs.Extensions.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | This package contains Azure Functions and Azure WebJobs binding extensions for Web Push Protocol based client for Push Service. 4 | Azure WebJobs AzureFunctions WebPush 5 | Copyright © 2019 - 2025 Tomasz Pęczek 6 | 1.5.1 7 | Tomasz Pęczek 8 | netstandard2.0 9 | Lib.Azure.WebJobs.Extensions.WebPush 10 | Lib.Azure.WebJobs.Extensions.WebPush 11 | Lib.Azure.WebJobs.Extensions.WebPush 12 | azure;webjobs;azurefunctions;webpush 13 | https://github.com/tpeczek/Lib.Net.Http.WebPush 14 | MIT 15 | README.md 16 | git 17 | git://github.com/tpeczek/Lib.Net.Http.WebPush 18 | true 19 | true 20 | true 21 | true 22 | true 23 | true 24 | latest 25 | true 26 | Lib.Azure.WebJobs.Extensions.WebPush.snk 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/Lib.Azure.WebJobs.Extensions.WebPush.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpeczek/Lib.Net.Http.WebPush/2d84bc3ef915223491b5417c205be596064ec4a8/src/Lib.Azure.WebJobs.Extensions.WebPush/Lib.Azure.WebJobs.Extensions.WebPush.snk -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/PushServiceOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Azure.WebJobs.Extensions.WebPush 2 | { 3 | /// 4 | /// Options for Push Service binding extensions. 5 | /// 6 | public class PushServiceOptions 7 | { 8 | /// 9 | /// The application server public key. 10 | /// 11 | public string PublicKey { get; set; } 12 | 13 | /// 14 | /// The application server private key. 15 | /// 16 | public string PrivateKey { get; set; } 17 | 18 | /// 19 | /// The contact information for the application server. 20 | /// 21 | public string Subject { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/PushServiceWebJobsBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.WebJobs; 3 | using Microsoft.Extensions.Http; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Lib.Azure.WebJobs.Extensions.WebPush.Config; 7 | 8 | namespace Lib.Azure.WebJobs.Extensions.WebPush 9 | { 10 | /// 11 | /// The extension methods for Push Service binding extensions. 12 | /// 13 | public static class PushServiceWebJobsBuilderExtensions 14 | { 15 | /// 16 | /// Adds the Push Service binding extensions to the provided . 17 | /// 18 | /// The to configure. 19 | /// An to configure the provided . 20 | public static IWebJobsBuilder AddPushService(this IWebJobsBuilder builder, Action configure) 21 | { 22 | if (builder is null) 23 | { 24 | throw new ArgumentNullException(nameof(builder)); 25 | } 26 | 27 | if (configure is null) 28 | { 29 | throw new ArgumentNullException(nameof(configure)); 30 | } 31 | 32 | builder.AddPushService(); 33 | builder.Services.Configure(configure); 34 | 35 | return builder; 36 | } 37 | 38 | /// 39 | /// Adds the Push Service binding extensions to the provided . 40 | /// 41 | /// The to configure. 42 | public static IWebJobsBuilder AddPushService(this IWebJobsBuilder builder) 43 | { 44 | if (builder is null) 45 | { 46 | throw new ArgumentNullException(nameof(builder)); 47 | } 48 | 49 | builder.AddExtension() 50 | .ConfigureOptions((config, path, options) => 51 | { 52 | config.GetSection(path).Bind(options); 53 | }); 54 | 55 | builder.Services.AddHttpClient(); 56 | builder.Services.Configure(options => options.SuppressHandlerScope = true); 57 | 58 | return builder; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Lib.Azure.WebJobs.Extensions.WebPush/PushServiceWebJobsStartup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.WebJobs; 2 | using Microsoft.Azure.WebJobs.Hosting; 3 | using Lib.Azure.WebJobs.Extensions.WebPush; 4 | 5 | [assembly: WebJobsStartup(typeof(PushServiceWebJobsStartup))] 6 | 7 | namespace Lib.Azure.WebJobs.Extensions.WebPush 8 | { 9 | /// 10 | /// Class defining a startup configuration action for Push Service binding extensions, which will be performed as part of host startup. 11 | /// 12 | public class PushServiceWebJobsStartup : IWebJobsStartup 13 | { 14 | /// 15 | /// Performs the startup configuration action for Push Service binding extensions. 16 | /// 17 | /// The that can be used to configure the host. 18 | public void Configure(IWebJobsBuilder builder) 19 | { 20 | builder.AddPushService(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Authentication/IVapidTokenCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Lib.Net.Http.WebPush.Authentication 4 | { 5 | /// 6 | /// Represents cache for tokens. 7 | /// 8 | public interface IVapidTokenCache 9 | { 10 | /// 11 | /// Puts token into cache. 12 | /// 13 | /// The origin of the push resource (cache key). 14 | /// The token expiration. 15 | /// The token. 16 | void Put(string audience, DateTimeOffset expiration, string token); 17 | 18 | /// 19 | /// Gets token from cache. 20 | /// 21 | /// The origin of the push resource (cache key). 22 | /// The cached token or null if token was not present in cache. 23 | string Get(string audience); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Authentication/VapidAuthentication.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Globalization; 4 | using Lib.Net.Http.WebPush.Internals; 5 | 6 | namespace Lib.Net.Http.WebPush.Authentication 7 | { 8 | /// 9 | /// Class which provides Voluntary Application Server Identification (VAPID) headers values. 10 | /// 11 | public class VapidAuthentication : IDisposable 12 | { 13 | #region Structures 14 | /// 15 | /// Structure providing values for headers used in case of . 16 | /// 17 | public readonly struct WebPushSchemeHeadersValues 18 | { 19 | /// 20 | /// Gets the parameter. 21 | /// 22 | public string AuthenticationHeaderValueParameter { get; } 23 | 24 | /// 25 | /// Gets the Crypto-Key header value. 26 | /// 27 | public string CryptoKeyHeaderValue { get; } 28 | 29 | internal WebPushSchemeHeadersValues(string authenticationHeaderValueParameter, string cryptoKeyHeaderValue) 30 | : this() 31 | { 32 | AuthenticationHeaderValueParameter = authenticationHeaderValueParameter; 33 | CryptoKeyHeaderValue = cryptoKeyHeaderValue; 34 | } 35 | } 36 | #endregion 37 | 38 | #region Fields 39 | private const string URI_SCHEME_HTTPS = "https"; 40 | 41 | private const int DEFAULT_EXPIRATION = 43200; 42 | private const int MAXIMUM_EXPIRATION = 86400; 43 | 44 | private const string JWT_HEADER = "{\"typ\":\"JWT\",\"alg\":\"ES256\"}"; 45 | private const string JWT_SEPARATOR = "."; 46 | private const string JWT_BODY_AUDIENCE_PART = "{\"aud\":\""; 47 | private const string JWT_BODY_EXPIRATION_PART = "\",\"exp\":"; 48 | private const string JWT_BODY_SUBJECT_PART = ",\"sub\":\""; 49 | private const string JWT_BODY_WITH_SUBJECT_CLOSING = "\"}"; 50 | private const string JWT_BODY_WITHOUT_SUBJECT_CLOSING = "}"; 51 | 52 | private const string P256ECDSA_PREFIX = "p256ecdsa="; 53 | private const string VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT = "t={0}, k={1}"; 54 | 55 | private string _subject; 56 | private string _publicKey; 57 | private string _privateKey; 58 | private int _relativeExpiration; 59 | 60 | private static readonly DateTime _unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0); 61 | private static readonly string _jwtHeaderSegment = UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(JWT_HEADER)); 62 | 63 | private ES256Signer _jwtSigner; 64 | #endregion 65 | 66 | #region Properties 67 | /// 68 | /// Gets or sets the contact information for the application server. 69 | /// 70 | public string Subject 71 | { 72 | get { return _subject; } 73 | 74 | set 75 | { 76 | if (!String.IsNullOrWhiteSpace(value)) 77 | { 78 | if (!value.StartsWith("mailto:")) 79 | { 80 | if (!Uri.IsWellFormedUriString(value, UriKind.Absolute) || ((new Uri(value)).Scheme != URI_SCHEME_HTTPS)) 81 | { 82 | throw new ArgumentException(nameof(Subject), "Subject should include a contact URI for the application server as either a 'mailto: ' (email) or an 'https:' URI"); 83 | } 84 | } 85 | 86 | _subject = value; 87 | } 88 | else 89 | { 90 | _subject = null; 91 | } 92 | } 93 | } 94 | 95 | /// 96 | /// Gets or sets the Application Server Public Key. 97 | /// 98 | public string PublicKey 99 | { 100 | get { return _publicKey; } 101 | 102 | set 103 | { 104 | if (String.IsNullOrWhiteSpace(value)) 105 | { 106 | throw new ArgumentNullException(nameof(PublicKey)); 107 | } 108 | 109 | byte[] decodedPublicKey = UrlBase64Converter.FromUrlBase64String(value); 110 | if (decodedPublicKey.Length != 65) 111 | { 112 | throw new ArgumentException(nameof(PublicKey), "VAPID public key must be 65 bytes long"); 113 | } 114 | 115 | _publicKey = value; 116 | } 117 | } 118 | 119 | /// 120 | /// Gets or sets the Application Server Private Key. 121 | /// 122 | public string PrivateKey 123 | { 124 | get { return _privateKey; } 125 | 126 | set 127 | { 128 | if (String.IsNullOrWhiteSpace(value)) 129 | { 130 | throw new ArgumentNullException(nameof(PrivateKey)); 131 | } 132 | 133 | byte[] decodedPrivateKey = UrlBase64Converter.FromUrlBase64String(value); 134 | if (decodedPrivateKey.Length != 32) 135 | { 136 | throw new ArgumentException(nameof(PrivateKey), "VAPID private key should be 32 bytes long"); 137 | } 138 | 139 | _privateKey = value; 140 | 141 | _jwtSigner = new ES256Signer(decodedPrivateKey); 142 | } 143 | } 144 | 145 | /// 146 | /// Gets or sets the time after which the authentication token expires (in seconds). 147 | /// 148 | public int Expiration 149 | { 150 | get { return _relativeExpiration; } 151 | 152 | set 153 | { 154 | if ((value <= 0) || (value > MAXIMUM_EXPIRATION)) 155 | { 156 | throw new ArgumentOutOfRangeException(nameof(Expiration), "Expiration must be a number of seconds not longer than 24 hours"); 157 | } 158 | 159 | _relativeExpiration = value; 160 | } 161 | } 162 | 163 | /// 164 | /// Gets or sets the token cache. 165 | /// 166 | public IVapidTokenCache TokenCache { get; set; } 167 | #endregion 168 | 169 | #region Constructor 170 | /// 171 | /// Creates new instance of class. 172 | /// 173 | /// The Application Server Public Key. 174 | /// The Application Server Private Key. 175 | public VapidAuthentication(string publicKey, string privateKey) 176 | { 177 | PublicKey = publicKey; 178 | PrivateKey = privateKey; 179 | 180 | _relativeExpiration = DEFAULT_EXPIRATION; 181 | } 182 | #endregion 183 | 184 | #region Methods 185 | /// 186 | /// Gets parameter for . 187 | /// 188 | /// The origin of the push resource. 189 | /// The parameter for . 190 | public string GetVapidSchemeAuthenticationHeaderValueParameter(string audience) 191 | { 192 | return String.Format(VAPID_AUTHENTICATION_HEADER_VALUE_PARAMETER_FORMAT, GetToken(audience), _publicKey); 193 | } 194 | 195 | /// 196 | /// Gets values for headers used in case of . 197 | /// 198 | /// The origin of the push resource. 199 | /// The values for headers used in case of . 200 | public WebPushSchemeHeadersValues GetWebPushSchemeHeadersValues(string audience) 201 | { 202 | return new WebPushSchemeHeadersValues(GetToken(audience), P256ECDSA_PREFIX + _publicKey); 203 | } 204 | 205 | /// 206 | /// Releases all resources used by the current instance of the class. 207 | /// 208 | public void Dispose() 209 | { 210 | _jwtSigner?.Dispose(); 211 | } 212 | 213 | private string GetToken(string audience) 214 | { 215 | if (String.IsNullOrWhiteSpace(audience)) 216 | { 217 | throw new ArgumentNullException(nameof(audience)); 218 | } 219 | 220 | if (!Uri.IsWellFormedUriString(audience, UriKind.Absolute)) 221 | { 222 | throw new ArgumentException(nameof(audience), "Audience should be an absolute URL"); 223 | } 224 | 225 | string token = TokenCache?.Get(audience); 226 | 227 | if (token == null) 228 | { 229 | DateTime absoluteExpiration = DateTime.UtcNow.AddSeconds(_relativeExpiration); 230 | 231 | token = GenerateToken(audience, absoluteExpiration); 232 | 233 | TokenCache?.Put(audience, absoluteExpiration, token); 234 | } 235 | 236 | return token; 237 | } 238 | 239 | private string GenerateToken(string audience, DateTime absoluteExpiration) 240 | { 241 | string jwtInput = _jwtHeaderSegment + JWT_SEPARATOR + GenerateJwtBodySegment(audience, absoluteExpiration); 242 | 243 | return jwtInput + JWT_SEPARATOR + UrlBase64Converter.ToUrlBase64String(_jwtSigner.GenerateSignature(jwtInput)); 244 | } 245 | 246 | private string GenerateJwtBodySegment(string audience, DateTime absoluteExpiration) 247 | { 248 | StringBuilder jwtBodyBuilder = new StringBuilder(); 249 | 250 | jwtBodyBuilder.Append(JWT_BODY_AUDIENCE_PART).Append(audience) 251 | .Append(JWT_BODY_EXPIRATION_PART).Append(ToUnixTimeSeconds(absoluteExpiration).ToString(CultureInfo.InvariantCulture)); 252 | 253 | if (_subject != null) 254 | { 255 | jwtBodyBuilder.Append(JWT_BODY_SUBJECT_PART).Append(_subject).Append(JWT_BODY_WITH_SUBJECT_CLOSING); 256 | } 257 | else 258 | { 259 | jwtBodyBuilder.Append(JWT_BODY_WITHOUT_SUBJECT_CLOSING); 260 | } 261 | 262 | return UrlBase64Converter.ToUrlBase64String(Encoding.UTF8.GetBytes(jwtBodyBuilder.ToString())); 263 | } 264 | 265 | private static long ToUnixTimeSeconds(DateTime dateTime) 266 | { 267 | TimeSpan unixEpochOffset = dateTime - _unixEpoch; 268 | 269 | return (long)unixEpochOffset.TotalSeconds; 270 | } 271 | #endregion 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Authentication/VapidAuthenticationScheme.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Net.Http.WebPush.Authentication 2 | { 3 | /// 4 | /// Voluntary Application Server Identification (VAPID) HTTP authentication schemes. 5 | /// 6 | public enum VapidAuthenticationScheme 7 | { 8 | /// 9 | /// The "WebPush" HTTP authentication scheme. 10 | /// 11 | WebPush, 12 | /// 13 | /// The "vapid" HTTP authentication scheme. 14 | /// 15 | Vapid 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/ECDHAgreement.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Net.Http.WebPush.Internals 2 | { 3 | internal readonly struct ECDHAgreement 4 | { 5 | public byte[] PublicKey { get; } 6 | 7 | public byte[] SharedSecretHmac { get; } 8 | 9 | public ECDHAgreement(byte[] publicKey, byte[] sharedSecretHmac) 10 | { 11 | PublicKey = publicKey; 12 | SharedSecretHmac = sharedSecretHmac; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/ECDHAgreementCalculator.BouncyCastle.cs: -------------------------------------------------------------------------------- 1 | #if NET451 || NET461 || NETSTANDARD2_0 2 | using System; 3 | using System.IO; 4 | using System.Security.Cryptography; 5 | using Org.BouncyCastle.Asn1; 6 | using Org.BouncyCastle.Crypto; 7 | using Org.BouncyCastle.Asn1.X9; 8 | using Org.BouncyCastle.OpenSsl; 9 | using Org.BouncyCastle.Security; 10 | using Org.BouncyCastle.Asn1.Nist; 11 | using Org.BouncyCastle.Crypto.Parameters; 12 | 13 | namespace Lib.Net.Http.WebPush.Internals 14 | { 15 | internal static class ECDHAgreementCalculator 16 | { 17 | private const string PRIVATE_DER_IDENTIFIER = "1.2.840.10045.3.1.7"; 18 | private const string PUBLIC_DER_IDENTIFIER = "1.2.840.10045.2.1"; 19 | 20 | private const string PUBLIC_PEM_KEY_PREFIX = "-----BEGIN PUBLIC KEY-----\n"; 21 | private const string PUBLIC_PEM_KEY_SUFFIX = "\n-----END PUBLIC KEY-----"; 22 | 23 | private const string P256_CURVE_NAME = "P-256"; 24 | private const string ECDH_ALGORITHM_NAME = "ECDH"; 25 | 26 | public static ECDHAgreement CalculateAgreement(byte[] otherPartyPublicKey, byte[] hmacKey) 27 | { 28 | AsymmetricCipherKeyPair agreementKeyPair = GenerateAsymmetricCipherKeyPair(); 29 | byte[] agreementPublicKey = ((ECPublicKeyParameters)agreementKeyPair.Public).Q.GetEncoded(false); 30 | 31 | IBasicAgreement agreement = AgreementUtilities.GetBasicAgreement(ECDH_ALGORITHM_NAME); 32 | agreement.Init(agreementKeyPair.Private); 33 | 34 | byte[] sharedSecret = agreement.CalculateAgreement(GetECPublicKeyParameters(otherPartyPublicKey)).ToByteArrayUnsigned(); 35 | 36 | return new ECDHAgreement(agreementPublicKey, HmacSha256(hmacKey, sharedSecret)); 37 | } 38 | 39 | private static AsymmetricCipherKeyPair GenerateAsymmetricCipherKeyPair() 40 | { 41 | X9ECParameters ecParameters = NistNamedCurves.GetByName(P256_CURVE_NAME); 42 | ECDomainParameters ecDomainParameters = new ECDomainParameters(ecParameters.Curve, ecParameters.G, ecParameters.N, ecParameters.H, ecParameters.GetSeed()); 43 | 44 | IAsymmetricCipherKeyPairGenerator keyPairGenerator = GeneratorUtilities.GetKeyPairGenerator(ECDH_ALGORITHM_NAME); 45 | keyPairGenerator.Init(new ECKeyGenerationParameters(ecDomainParameters, new SecureRandom())); 46 | 47 | return keyPairGenerator.GenerateKeyPair(); 48 | } 49 | 50 | private static ECPublicKeyParameters GetECPublicKeyParameters(byte[] publicKey) 51 | { 52 | Asn1Object derSequence = new DerSequence( 53 | new DerSequence(new DerObjectIdentifier(PUBLIC_DER_IDENTIFIER), new DerObjectIdentifier(PRIVATE_DER_IDENTIFIER)), 54 | new DerBitString(publicKey) 55 | ); 56 | 57 | string pemKey = PUBLIC_PEM_KEY_PREFIX 58 | + Convert.ToBase64String(derSequence.GetDerEncoded()) 59 | + PUBLIC_PEM_KEY_SUFFIX; 60 | 61 | PemReader pemKeyReader = new PemReader(new StringReader(pemKey)); 62 | return (ECPublicKeyParameters)pemKeyReader.ReadObject(); 63 | } 64 | 65 | private static byte[] HmacSha256(byte[] key, byte[] value) 66 | { 67 | byte[] hash = null; 68 | 69 | using (HMACSHA256 hasher = new HMACSHA256(key)) 70 | { 71 | hash = hasher.ComputeHash(value); 72 | } 73 | 74 | return hash; 75 | } 76 | } 77 | } 78 | #endif -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/ECDHAgreementCalculator.NET.cs: -------------------------------------------------------------------------------- 1 | #if !NET451 && !NET461 && !NETSTANDARD2_0 2 | using System; 3 | using System.Formats.Asn1; 4 | using System.Security.Cryptography; 5 | 6 | namespace Lib.Net.Http.WebPush.Internals 7 | { 8 | internal static class ECDHAgreementCalculator 9 | { 10 | private const string PRIVATE_DER_IDENTIFIER = "1.2.840.10045.3.1.7"; 11 | private const string PUBLIC_DER_IDENTIFIER = "1.2.840.10045.2.1"; 12 | 13 | private const string PUBLIC_PEM_KEY_PREFIX = "-----BEGIN PUBLIC KEY-----"; 14 | private const string PUBLIC_PEM_KEY_SUFFIX = "-----END PUBLIC KEY-----"; 15 | 16 | public static ECDHAgreement CalculateAgreement(byte[] otherPartyPublicKey, byte[] hmacKey) 17 | { 18 | using (ECDiffieHellman agreement = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256)) 19 | { 20 | byte[] agreementPublicKey = GetAgreementPublicKey(agreement); 21 | 22 | byte[] sharedSecretHmac = agreement.DeriveKeyFromHmac(GetECDiffieHellmanPublicKey(otherPartyPublicKey), HashAlgorithmName.SHA256, hmacKey); 23 | 24 | return new ECDHAgreement(agreementPublicKey, sharedSecretHmac); 25 | } 26 | } 27 | 28 | private static byte[] GetAgreementPublicKey(ECDiffieHellman agreement) 29 | { 30 | ECParameters agreementParameters = agreement.ExportParameters(false); 31 | 32 | byte[] agreementPublicKey = new byte[agreementParameters.Q.X.Length + agreementParameters.Q.Y.Length + 1]; 33 | 34 | agreementPublicKey[0] = 0x04; 35 | Array.Copy(agreementParameters.Q.X, 0, agreementPublicKey, 1, agreementParameters.Q.X.Length); 36 | Array.Copy(agreementParameters.Q.Y, 0, agreementPublicKey, agreementParameters.Q.X.Length + 1, agreementParameters.Q.Y.Length); 37 | 38 | return agreementPublicKey; 39 | } 40 | 41 | private static ECDiffieHellmanPublicKey GetECDiffieHellmanPublicKey(byte[] publicKey) 42 | { 43 | using (ECDiffieHellman ecdhAgreement = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256)) 44 | { 45 | ecdhAgreement.ImportFromPem(GetPublicKeyPem(publicKey)); 46 | 47 | return ecdhAgreement.PublicKey; 48 | } 49 | } 50 | 51 | private static ReadOnlySpan GetPublicKeyPem(byte[] publicKey) 52 | { 53 | AsnWriter asnWriter = new AsnWriter(AsnEncodingRules.DER); 54 | asnWriter.PushSequence(); 55 | asnWriter.PushSequence(); 56 | asnWriter.WriteObjectIdentifier(PUBLIC_DER_IDENTIFIER); 57 | asnWriter.WriteObjectIdentifier(PRIVATE_DER_IDENTIFIER); 58 | asnWriter.PopSequence(); 59 | asnWriter.WriteBitString(publicKey); 60 | asnWriter.PopSequence(); 61 | 62 | return PUBLIC_PEM_KEY_PREFIX + Environment.NewLine 63 | + Convert.ToBase64String(asnWriter.Encode()) + Environment.NewLine 64 | + PUBLIC_PEM_KEY_SUFFIX; 65 | } 66 | } 67 | } 68 | #endif -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/ES256Signer.BouncyCastle.cs: -------------------------------------------------------------------------------- 1 | #if NET451 || NET461 || NETSTANDARD2_0 2 | using System; 3 | using System.IO; 4 | using System.Text; 5 | using System.Security.Cryptography; 6 | using Org.BouncyCastle.Asn1; 7 | using Org.BouncyCastle.Math; 8 | using Org.BouncyCastle.Crypto; 9 | using Org.BouncyCastle.OpenSsl; 10 | using Org.BouncyCastle.Crypto.Signers; 11 | using Org.BouncyCastle.Crypto.Parameters; 12 | 13 | namespace Lib.Net.Http.WebPush.Internals 14 | { 15 | internal class ES256Signer : IDisposable 16 | { 17 | private const string PRIVATE_DER_IDENTIFIER = "1.2.840.10045.3.1.7"; 18 | private const string PRIVATE_PEM_KEY_PREFIX = "-----BEGIN EC PRIVATE KEY-----\n"; 19 | private const string PRIVATE_PEM_KEY_SUFFIX = "\n-----END EC PRIVATE KEY----"; 20 | 21 | private readonly ECDsaSigner _internalSigner; 22 | 23 | public ES256Signer(byte[] privateKey) 24 | { 25 | _internalSigner = new ECDsaSigner(); 26 | _internalSigner.Init(true, GetECPrivateKeyParameters(privateKey)); 27 | } 28 | 29 | public byte[] GenerateSignature(string input) 30 | { 31 | byte[] hash = ComputeHash(input); 32 | 33 | BigInteger[] signature = _internalSigner.GenerateSignature(hash); 34 | 35 | byte[] jwtSignatureFirstSegment = signature[0].ToByteArrayUnsigned(); 36 | byte[] jwtSignatureSecondSegment = signature[1].ToByteArrayUnsigned(); 37 | 38 | int jwtSignatureSegmentLength = Math.Max(jwtSignatureFirstSegment.Length, jwtSignatureSecondSegment.Length); 39 | byte[] combinedJwtSignature = new byte[2 * jwtSignatureSegmentLength]; 40 | ByteArrayCopyWithPadLeft(jwtSignatureFirstSegment, combinedJwtSignature, 0, jwtSignatureSegmentLength); 41 | ByteArrayCopyWithPadLeft(jwtSignatureSecondSegment, combinedJwtSignature, jwtSignatureSegmentLength, jwtSignatureSegmentLength); 42 | 43 | 44 | return combinedJwtSignature; 45 | } 46 | 47 | public void Dispose() 48 | { } 49 | 50 | private static byte[] ComputeHash(string input) 51 | { 52 | using (var sha256Hasher = SHA256.Create()) 53 | { 54 | return sha256Hasher.ComputeHash(Encoding.UTF8.GetBytes(input)); 55 | } 56 | } 57 | 58 | private static ECPrivateKeyParameters GetECPrivateKeyParameters(byte[] privateKey) 59 | { 60 | Asn1Object derSequence = new DerSequence( 61 | new DerInteger(1), 62 | new DerOctetString(privateKey), 63 | new DerTaggedObject(0, new DerObjectIdentifier(PRIVATE_DER_IDENTIFIER)) 64 | ); 65 | 66 | string pemKey = PRIVATE_PEM_KEY_PREFIX 67 | + Convert.ToBase64String(derSequence.GetDerEncoded()) 68 | + PRIVATE_PEM_KEY_SUFFIX; 69 | 70 | PemReader pemKeyReader = new PemReader(new StringReader(pemKey)); 71 | AsymmetricCipherKeyPair keyPair = (AsymmetricCipherKeyPair)pemKeyReader.ReadObject(); 72 | 73 | return (ECPrivateKeyParameters)keyPair.Private; 74 | } 75 | 76 | private static void ByteArrayCopyWithPadLeft(byte[] sourceArray, byte[] destinationArray, int destinationIndex, int destinationLengthToUse) 77 | { 78 | if (sourceArray.Length != destinationLengthToUse) 79 | { 80 | destinationIndex += (destinationLengthToUse - sourceArray.Length); 81 | } 82 | 83 | Buffer.BlockCopy(sourceArray, 0, destinationArray, destinationIndex, sourceArray.Length); 84 | } 85 | } 86 | } 87 | #endif -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/ES256Signer.NET.cs: -------------------------------------------------------------------------------- 1 | #if !NET451 && !NET461 && !NETSTANDARD2_0 2 | using System; 3 | using System.Text; 4 | using System.Formats.Asn1; 5 | using System.Security.Cryptography; 6 | 7 | namespace Lib.Net.Http.WebPush.Internals 8 | { 9 | internal class ES256Signer : IDisposable 10 | { 11 | private const string PRIVATE_DER_IDENTIFIER = "1.2.840.10045.3.1.7"; 12 | private const string PRIVATE_PEM_KEY_PREFIX = "-----BEGIN EC PRIVATE KEY-----"; 13 | private const string PRIVATE_PEM_KEY_SUFFIX = "-----END EC PRIVATE KEY-----"; 14 | 15 | private readonly ECDsa _internalSigner; 16 | 17 | public ES256Signer(byte[] privateKey) 18 | { 19 | _internalSigner = ECDsa.Create(ECCurve.NamedCurves.nistP256); 20 | _internalSigner.ImportFromPem(GetPrivateKeyPem(privateKey)); 21 | } 22 | 23 | public byte[] GenerateSignature(string input) 24 | { 25 | return _internalSigner.SignData(Encoding.UTF8.GetBytes(input), HashAlgorithmName.SHA256); 26 | } 27 | 28 | public void Dispose() 29 | { 30 | _internalSigner?.Dispose(); 31 | } 32 | 33 | private static ReadOnlySpan GetPrivateKeyPem(byte[] privateKey) 34 | { 35 | AsnWriter asnWriter = new AsnWriter(AsnEncodingRules.DER); 36 | asnWriter.PushSequence(); 37 | asnWriter.WriteInteger(1); 38 | asnWriter.WriteOctetString(privateKey); 39 | asnWriter.PushSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true)); 40 | asnWriter.WriteObjectIdentifier(PRIVATE_DER_IDENTIFIER); 41 | asnWriter.PopSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true)); 42 | asnWriter.PopSequence(); 43 | 44 | return PRIVATE_PEM_KEY_PREFIX + Environment.NewLine 45 | + Convert.ToBase64String(asnWriter.Encode()) + Environment.NewLine 46 | + PRIVATE_PEM_KEY_SUFFIX; 47 | } 48 | } 49 | } 50 | #endif -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Internals/UrlBase64Converter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Lib.Net.Http.WebPush.Internals 4 | { 5 | internal static class UrlBase64Converter 6 | { 7 | internal static byte[] FromUrlBase64String(string input) 8 | { 9 | input = input.Replace('-', '+').Replace('_', '/'); 10 | 11 | while (input.Length % 4 != 0) 12 | { 13 | input += "="; 14 | } 15 | 16 | return Convert.FromBase64String(input); 17 | } 18 | 19 | internal static string ToUrlBase64String(byte[] input) 20 | { 21 | return Convert.ToBase64String(input).Replace('+', '-').Replace('/', '_').TrimEnd('='); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Lib.Net.Http.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Lib.Net.Http.WebPush is a library which provides a Web Push Protocol based client for Push Service. 4 | Copyright © 2018 - 2025 Tomasz Pęczek 5 | 3.3.1 6 | Tomasz Pęczek 7 | net451;net461;netstandard2.0;net5.0;net6.0 8 | Lib.Net.Http.WebPush 9 | Lib.Net.Http.WebPush 10 | Lib.Net.Http.WebPush 11 | push;notifications;webpush;vapid 12 | https://github.com/tpeczek/Lib.Net.Http.WebPush 13 | MIT 14 | README.md 15 | git 16 | git://github.com/tpeczek/Lib.Net.Http.WebPush 17 | true 18 | true 19 | true 20 | true 21 | true 22 | true 23 | latest 24 | true 25 | Lib.Net.Http.WebPush.snk 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/Lib.Net.Http.WebPush.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpeczek/Lib.Net.Http.WebPush/2d84bc3ef915223491b5417c205be596064ec4a8/src/Lib.Net.Http.WebPush/Lib.Net.Http.WebPush.snk -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushEncryptionKeyName.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Net.Http.WebPush 2 | { 3 | /// 4 | /// The client keys shared as part of subscription. 5 | /// 6 | public enum PushEncryptionKeyName 7 | { 8 | /// 9 | /// The client P-256 public key for use in ECDH. 10 | /// 11 | P256DH, 12 | /// 13 | /// The client authentication secret. 14 | /// 15 | Auth 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Net.Http; 4 | 5 | namespace Lib.Net.Http.WebPush 6 | { 7 | /// 8 | /// Class representing a push message. 9 | /// 10 | public class PushMessage 11 | { 12 | #region Fields 13 | private static readonly string NOT_INSTANTIATED_THROUGH_STRING_BASED_CONSTRUCTOR = $"The {nameof(PushMessage)} instance hasn't been instantianted through string based constructor."; 14 | 15 | private readonly bool _stringBased; 16 | 17 | private string _content = null; 18 | private readonly HttpContent _httpContent = null; 19 | private int? _timeToLive; 20 | #endregion 21 | 22 | #region Properties 23 | /// 24 | /// Gets or sets the topic (used to correlate messages sent to the same subscription). 25 | /// 26 | public string Topic { get; set; } 27 | 28 | /// 29 | /// Gets or sets the content when a instance has been instantiated through . 30 | /// 31 | public string Content 32 | { 33 | get 34 | { 35 | if (!_stringBased) 36 | { 37 | throw new InvalidOperationException(NOT_INSTANTIATED_THROUGH_STRING_BASED_CONSTRUCTOR); 38 | } 39 | 40 | return _content; 41 | } 42 | 43 | set 44 | { 45 | if (!_stringBased) 46 | { 47 | throw new InvalidOperationException(NOT_INSTANTIATED_THROUGH_STRING_BASED_CONSTRUCTOR); 48 | } 49 | 50 | _content = value; 51 | } 52 | } 53 | 54 | /// 55 | /// Gets or sets the content. 56 | /// 57 | public HttpContent HttpContent 58 | { 59 | get 60 | { 61 | if (_stringBased) 62 | { 63 | return (_content is null) ? null : new StringContent(_content, Encoding.UTF8); 64 | } 65 | 66 | return _httpContent; 67 | } 68 | } 69 | 70 | /// 71 | /// Gets or sets the time (in seconds) for which the message should be retained by push service. 72 | /// 73 | public int? TimeToLive 74 | { 75 | get { return _timeToLive; } 76 | 77 | set 78 | { 79 | if (value.HasValue && (value.Value < 0)) 80 | { 81 | throw new ArgumentOutOfRangeException(nameof(TimeToLive), "The TTL must be a non-negative integer"); 82 | } 83 | 84 | _timeToLive = value; 85 | } 86 | } 87 | 88 | /// 89 | /// Gets or sets the message urgency. 90 | /// 91 | public PushMessageUrgency Urgency { get; set; } 92 | #endregion 93 | 94 | #region Constructors 95 | /// 96 | /// Creates new instance of class. 97 | /// 98 | /// The content. 99 | public PushMessage(string content) 100 | { 101 | _stringBased = true; 102 | Content = content; 103 | Urgency = PushMessageUrgency.Normal; 104 | } 105 | 106 | /// 107 | /// Creates new instance of class. 108 | /// 109 | /// The content. 110 | public PushMessage(HttpContent content) 111 | { 112 | _stringBased = false; 113 | _httpContent = content; 114 | Urgency = PushMessageUrgency.Normal; 115 | } 116 | #endregion 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushMessageUrgency.cs: -------------------------------------------------------------------------------- 1 | namespace Lib.Net.Http.WebPush 2 | { 3 | /// 4 | /// The push message urgency. 5 | /// 6 | public enum PushMessageUrgency 7 | { 8 | /// 9 | /// Very low (e.g. advertisements). 10 | /// 11 | VeryLow, 12 | /// 13 | /// Low (e.g. topic updates). 14 | /// 15 | Low, 16 | /// 17 | /// Normal (e.g. chat message). 18 | /// 19 | Normal, 20 | /// 21 | /// High (e.g. time-sensitive alert). 22 | /// 23 | High 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushServiceClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Headers; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Globalization; 8 | using System.Threading.Tasks; 9 | using System.Collections.Generic; 10 | using System.Security.Cryptography; 11 | using Lib.Net.Http.EncryptedContentEncoding; 12 | using Lib.Net.Http.WebPush.Internals; 13 | using Lib.Net.Http.WebPush.Authentication; 14 | 15 | namespace Lib.Net.Http.WebPush 16 | { 17 | /// 18 | /// A Web Push Protocol compliant client for push service. 19 | /// 20 | /// 21 | /// The should be considered an expensive object as it internally holds an instance of class. In order to avoid Improper Instantiation antipattern a shared singleton instance should be created or a pool of reusable instances should be used. 22 | /// 23 | public class PushServiceClient 24 | { 25 | #region Fields 26 | private const string TTL_HEADER_NAME = "TTL"; 27 | private const string TOPIC_HEADER_NAME = "Topic"; 28 | private const string URGENCY_HEADER_NAME = "Urgency"; 29 | private const string CRYPTO_KEY_HEADER_NAME = "Crypto-Key"; 30 | private const string WEBPUSH_AUTHENTICATION_SCHEME = "WebPush"; 31 | private const string VAPID_AUTHENTICATION_SCHEME = "vapid"; 32 | 33 | private const int DEFAULT_TIME_TO_LIVE = 2419200; 34 | 35 | private const string KEYING_MATERIAL_INFO_PARAMETER_PREFIX = "WebPush: info"; 36 | private const byte KEYING_MATERIAL_INFO_PARAMETER_DELIMITER = 1; 37 | 38 | private const int CONTENT_RECORD_SIZE = 4096; 39 | 40 | private static readonly byte[] _keyingMaterialInfoParameterPrefix = Encoding.ASCII.GetBytes(KEYING_MATERIAL_INFO_PARAMETER_PREFIX); 41 | private static readonly Dictionary _urgencyHeaderValues = new Dictionary 42 | { 43 | { PushMessageUrgency.VeryLow, "very-low" }, 44 | { PushMessageUrgency.Low, "low" }, 45 | { PushMessageUrgency.High, "high" } 46 | }; 47 | 48 | private int _defaultTimeToLive = DEFAULT_TIME_TO_LIVE; 49 | 50 | private readonly HttpClient _httpClient; 51 | #endregion 52 | 53 | #region Properties 54 | /// 55 | /// Gets or sets the value indicating if client should automatically attempt to retry in case of 429 Too Many Requests. 56 | /// 57 | public bool AutoRetryAfter { get; set; } = true; 58 | 59 | /// 60 | /// Gets or sets the value indicating the maximum number of automatic attempts to retry in case of 429 Too Many Requests (<= 0 means unlimited). 61 | /// 62 | public int MaxRetriesAfter { get; set; } = 0; 63 | 64 | /// 65 | /// Gets or sets the default time (in seconds) for which the message should be retained by push service. It will be used when is not set. 66 | /// 67 | public int DefaultTimeToLive 68 | { 69 | get { return _defaultTimeToLive; } 70 | 71 | set 72 | { 73 | if (value < 0) 74 | { 75 | throw new ArgumentOutOfRangeException(nameof(DefaultTimeToLive), "The TTL must be a non-negative integer"); 76 | } 77 | 78 | _defaultTimeToLive = value; 79 | } 80 | } 81 | 82 | /// 83 | /// Gets or sets the default authentication details. 84 | /// 85 | public VapidAuthentication DefaultAuthentication { get; set; } 86 | 87 | /// 88 | /// Gets or sets the default to be used. 89 | /// 90 | public VapidAuthenticationScheme DefaultAuthenticationScheme { get; set; } = VapidAuthenticationScheme.Vapid; 91 | #endregion 92 | 93 | #region Constructors 94 | /// 95 | /// Creates new instance of class. 96 | /// 97 | public PushServiceClient() 98 | : this(new HttpClient()) 99 | { } 100 | 101 | /// 102 | /// Creates new instance of class. 103 | /// 104 | /// The HttpClient instance. 105 | public PushServiceClient(HttpClient httpClient) 106 | { 107 | _httpClient = httpClient; 108 | } 109 | #endregion 110 | 111 | #region Methods 112 | /// 113 | /// Requests delivery of push message by push service as an asynchronous operation. 114 | /// 115 | /// The push service subscription. 116 | /// The push message. 117 | /// The task object representing the asynchronous operation. 118 | public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message) 119 | { 120 | return RequestPushMessageDeliveryAsync(subscription, message, null, DefaultAuthenticationScheme, CancellationToken.None); 121 | } 122 | 123 | /// 124 | /// Requests delivery of push message by push service as an asynchronous operation. 125 | /// 126 | /// The push service subscription. 127 | /// The push message. 128 | /// The cancellation token to cancel operation. 129 | /// The task object representing the asynchronous operation. 130 | public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, CancellationToken cancellationToken) 131 | { 132 | return RequestPushMessageDeliveryAsync(subscription, message, null, DefaultAuthenticationScheme, cancellationToken); 133 | } 134 | 135 | /// 136 | /// Requests delivery of push message by push service as an asynchronous operation. 137 | /// 138 | /// The push service subscription. 139 | /// The push message. 140 | /// The authentication details. 141 | /// The task object representing the asynchronous operation. 142 | public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication) 143 | { 144 | return RequestPushMessageDeliveryAsync(subscription, message, authentication, DefaultAuthenticationScheme, CancellationToken.None); 145 | } 146 | 147 | /// 148 | /// Requests delivery of push message by push service as an asynchronous operation. 149 | /// 150 | /// The push service subscription. 151 | /// The push message. 152 | /// The authentication details. 153 | /// The to use. 154 | /// The task object representing the asynchronous operation. 155 | public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication, VapidAuthenticationScheme authenticationScheme) 156 | { 157 | return RequestPushMessageDeliveryAsync(subscription, message, authentication, authenticationScheme, CancellationToken.None); 158 | } 159 | 160 | /// 161 | /// Requests delivery of push message by push service as an asynchronous operation. 162 | /// 163 | /// The push service subscription. 164 | /// The push message. 165 | /// The authentication details. 166 | /// The cancellation token to cancel operation. 167 | /// The task object representing the asynchronous operation. 168 | public Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication, CancellationToken cancellationToken) 169 | { 170 | return RequestPushMessageDeliveryAsync(subscription, message, authentication, DefaultAuthenticationScheme, cancellationToken); 171 | } 172 | 173 | /// 174 | /// Requests delivery of push message by push service as an asynchronous operation. 175 | /// 176 | /// The push service subscription. 177 | /// The push message. 178 | /// The authentication details. 179 | /// The to use. 180 | /// The cancellation token to cancel operation. 181 | /// The task object representing the asynchronous operation. 182 | public async Task RequestPushMessageDeliveryAsync(PushSubscription subscription, PushMessage message, VapidAuthentication authentication, VapidAuthenticationScheme authenticationScheme, CancellationToken cancellationToken) 183 | { 184 | HttpRequestMessage pushMessageDeliveryRequest = PreparePushMessageDeliveryRequest(subscription, message, authentication, authenticationScheme); 185 | HttpResponseMessage pushMessageDeliveryRequestResponse = null; 186 | 187 | try 188 | { 189 | pushMessageDeliveryRequestResponse = await _httpClient.SendAsync(pushMessageDeliveryRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); 190 | 191 | int retriesAfterCount = 0; 192 | while (ShouldRetryAfter(pushMessageDeliveryRequestResponse, retriesAfterCount, out TimeSpan delay)) 193 | { 194 | pushMessageDeliveryRequest.Dispose(); 195 | pushMessageDeliveryRequestResponse.Dispose(); 196 | 197 | await Task.Delay(delay, cancellationToken); 198 | 199 | pushMessageDeliveryRequest = PreparePushMessageDeliveryRequest(subscription, message, authentication, authenticationScheme); 200 | pushMessageDeliveryRequestResponse = await _httpClient.SendAsync(pushMessageDeliveryRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); 201 | 202 | retriesAfterCount++; 203 | } 204 | 205 | await HandlePushMessageDeliveryRequestResponse(pushMessageDeliveryRequestResponse, subscription); 206 | } 207 | finally 208 | { 209 | pushMessageDeliveryRequest.Dispose(); 210 | pushMessageDeliveryRequestResponse?.Dispose(); 211 | } 212 | } 213 | 214 | private HttpRequestMessage PreparePushMessageDeliveryRequest(PushSubscription subscription, PushMessage message, VapidAuthentication authentication, VapidAuthenticationScheme authenticationScheme) 215 | { 216 | authentication = authentication ?? DefaultAuthentication; 217 | if (authentication == null) 218 | { 219 | throw new InvalidOperationException("The VAPID authentication information is not available"); 220 | } 221 | 222 | HttpRequestMessage pushMessageDeliveryRequest = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint) 223 | { 224 | Headers = 225 | { 226 | { TTL_HEADER_NAME, (message.TimeToLive ?? DefaultTimeToLive).ToString(CultureInfo.InvariantCulture) } 227 | } 228 | }; 229 | pushMessageDeliveryRequest = SetAuthentication(pushMessageDeliveryRequest, subscription, authentication, authenticationScheme); 230 | pushMessageDeliveryRequest = SetUrgency(pushMessageDeliveryRequest, message); 231 | pushMessageDeliveryRequest = SetTopic(pushMessageDeliveryRequest, message); 232 | pushMessageDeliveryRequest = SetContent(pushMessageDeliveryRequest, subscription, message); 233 | 234 | return pushMessageDeliveryRequest; 235 | } 236 | 237 | private static HttpRequestMessage SetAuthentication(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, VapidAuthentication authentication, VapidAuthenticationScheme authenticationScheme) 238 | { 239 | Uri endpointUri = new Uri(subscription.Endpoint); 240 | string audience = endpointUri.Scheme + @"://" + endpointUri.Host; 241 | 242 | if (authenticationScheme == VapidAuthenticationScheme.WebPush) 243 | { 244 | VapidAuthentication.WebPushSchemeHeadersValues webPushSchemeHeadersValues = authentication.GetWebPushSchemeHeadersValues(audience); 245 | 246 | pushMessageDeliveryRequest.Headers.Authorization = new AuthenticationHeaderValue(WEBPUSH_AUTHENTICATION_SCHEME, webPushSchemeHeadersValues.AuthenticationHeaderValueParameter); 247 | pushMessageDeliveryRequest.Headers.Add(CRYPTO_KEY_HEADER_NAME, webPushSchemeHeadersValues.CryptoKeyHeaderValue); 248 | } 249 | else 250 | { 251 | pushMessageDeliveryRequest.Headers.Authorization = new AuthenticationHeaderValue(VAPID_AUTHENTICATION_SCHEME, authentication.GetVapidSchemeAuthenticationHeaderValueParameter(audience)); 252 | } 253 | 254 | return pushMessageDeliveryRequest; 255 | } 256 | 257 | private static HttpRequestMessage SetUrgency(HttpRequestMessage pushMessageDeliveryRequest, PushMessage message) 258 | { 259 | switch (message.Urgency) 260 | { 261 | case PushMessageUrgency.Normal: 262 | break; 263 | case PushMessageUrgency.VeryLow: 264 | case PushMessageUrgency.Low: 265 | case PushMessageUrgency.High: 266 | pushMessageDeliveryRequest.Headers.Add(URGENCY_HEADER_NAME, _urgencyHeaderValues[message.Urgency]); 267 | break; 268 | default: 269 | throw new NotSupportedException($"Not supported value has been provided for {nameof(PushMessageUrgency)}."); 270 | } 271 | 272 | return pushMessageDeliveryRequest; 273 | } 274 | 275 | private static HttpRequestMessage SetTopic(HttpRequestMessage pushMessageDeliveryRequest, PushMessage message) 276 | { 277 | if (!String.IsNullOrWhiteSpace(message.Topic)) 278 | { 279 | pushMessageDeliveryRequest.Headers.Add(TOPIC_HEADER_NAME, message.Topic); 280 | } 281 | 282 | return pushMessageDeliveryRequest; 283 | } 284 | 285 | private static HttpRequestMessage SetContent(HttpRequestMessage pushMessageDeliveryRequest, PushSubscription subscription, PushMessage message) 286 | { 287 | HttpContent httpContent = message.HttpContent; 288 | if (httpContent is null) 289 | { 290 | pushMessageDeliveryRequest.Content = null; 291 | } 292 | else 293 | { 294 | string p256dhKey = subscription.GetKey(PushEncryptionKeyName.P256DH); 295 | string authKey = subscription.GetKey(PushEncryptionKeyName.Auth); 296 | 297 | if (String.IsNullOrWhiteSpace(p256dhKey) || String.IsNullOrWhiteSpace(authKey)) 298 | { 299 | throw new InvalidOperationException("The client P-256 public key or authentication secret is not available"); 300 | } 301 | 302 | ECDHAgreement keyAgreement = ECDHAgreementCalculator.CalculateAgreement 303 | ( 304 | UrlBase64Converter.FromUrlBase64String(p256dhKey), 305 | UrlBase64Converter.FromUrlBase64String(authKey) 306 | ); 307 | 308 | pushMessageDeliveryRequest.Content = new Aes128GcmEncodedContent( 309 | httpContent, 310 | GetKeyingMaterial(subscription, keyAgreement), 311 | keyAgreement.PublicKey, 312 | CONTENT_RECORD_SIZE 313 | ); 314 | } 315 | 316 | return pushMessageDeliveryRequest; 317 | } 318 | 319 | private static byte[] GetKeyingMaterial(PushSubscription subscription, ECDHAgreement keyAgreement) 320 | { 321 | byte[] userAgentPublicKey = UrlBase64Converter.FromUrlBase64String(subscription.GetKey(PushEncryptionKeyName.P256DH)); 322 | 323 | byte[] infoParameter = GetKeyingMaterialInfoParameter(userAgentPublicKey, keyAgreement.PublicKey); 324 | byte[] keyingMaterial = HmacSha256(keyAgreement.SharedSecretHmac, infoParameter); 325 | 326 | return keyingMaterial; 327 | } 328 | 329 | private static byte[] GetKeyingMaterialInfoParameter(byte[] userAgentPublicKey, byte[] applicationServerPublicKey) 330 | { 331 | // "WebPush: info" || 0x00 || ua_public || as_public || 0x01 332 | byte[] infoParameter = new byte[_keyingMaterialInfoParameterPrefix.Length + userAgentPublicKey.Length + applicationServerPublicKey.Length + 2]; 333 | 334 | Buffer.BlockCopy(_keyingMaterialInfoParameterPrefix, 0, infoParameter, 0, _keyingMaterialInfoParameterPrefix.Length); 335 | int infoParameterIndex = _keyingMaterialInfoParameterPrefix.Length + 1; 336 | 337 | Buffer.BlockCopy(userAgentPublicKey, 0, infoParameter, infoParameterIndex, userAgentPublicKey.Length); 338 | infoParameterIndex += userAgentPublicKey.Length; 339 | 340 | Buffer.BlockCopy(applicationServerPublicKey, 0, infoParameter, infoParameterIndex, applicationServerPublicKey.Length); 341 | infoParameter[infoParameter.Length - 1] = KEYING_MATERIAL_INFO_PARAMETER_DELIMITER; 342 | 343 | return infoParameter; 344 | } 345 | 346 | private static byte[] HmacSha256(byte[] key, byte[] value) 347 | { 348 | byte[] hash = null; 349 | 350 | using (HMACSHA256 hasher = new HMACSHA256(key)) 351 | { 352 | hash = hasher.ComputeHash(value); 353 | } 354 | 355 | return hash; 356 | } 357 | 358 | private bool ShouldRetryAfter(HttpResponseMessage pushMessageDeliveryRequestResponse, int retriesAfterCount, out TimeSpan delay) 359 | { 360 | delay = TimeSpan.MinValue; 361 | 362 | if ((pushMessageDeliveryRequestResponse.StatusCode != (HttpStatusCode)429) || !AutoRetryAfter) 363 | { 364 | return false; 365 | } 366 | 367 | if ((MaxRetriesAfter > 0) && (retriesAfterCount >= MaxRetriesAfter)) 368 | { 369 | return false; 370 | } 371 | 372 | if ((pushMessageDeliveryRequestResponse.Headers.RetryAfter is null) || (!pushMessageDeliveryRequestResponse.Headers.RetryAfter.Date.HasValue && !pushMessageDeliveryRequestResponse.Headers.RetryAfter.Delta.HasValue)) 373 | { 374 | return false; 375 | } 376 | 377 | if (pushMessageDeliveryRequestResponse.Headers.RetryAfter.Delta.HasValue) 378 | { 379 | delay = pushMessageDeliveryRequestResponse.Headers.RetryAfter.Delta.Value; 380 | } 381 | 382 | if (pushMessageDeliveryRequestResponse.Headers.RetryAfter.Date.HasValue) 383 | { 384 | delay = pushMessageDeliveryRequestResponse.Headers.RetryAfter.Date.Value.Subtract(DateTimeOffset.UtcNow); 385 | } 386 | 387 | return true; 388 | } 389 | 390 | private static async Task HandlePushMessageDeliveryRequestResponse(HttpResponseMessage pushMessageDeliveryRequestResponse, PushSubscription subscription) 391 | { 392 | if (PushMessageDeliverySuccessful(pushMessageDeliveryRequestResponse.StatusCode)) 393 | { 394 | return; 395 | } 396 | 397 | string reason = String.IsNullOrWhiteSpace(pushMessageDeliveryRequestResponse.ReasonPhrase) ? 398 | $"Received unexpected response code: {pushMessageDeliveryRequestResponse.StatusCode}" 399 | : pushMessageDeliveryRequestResponse.ReasonPhrase; 400 | 401 | string content = await pushMessageDeliveryRequestResponse.Content.ReadAsStringAsync(); 402 | 403 | throw new PushServiceClientException(reason, pushMessageDeliveryRequestResponse.StatusCode, pushMessageDeliveryRequestResponse.Headers, content, subscription); 404 | } 405 | 406 | private static bool PushMessageDeliverySuccessful(HttpStatusCode statusCode) 407 | { 408 | switch (statusCode) 409 | { 410 | case HttpStatusCode.Created: // RFC 8030 411 | case HttpStatusCode.OK: // Mozilla Autopush Server (Delivered to node client is connected to) 412 | case HttpStatusCode.Accepted: // Mozilla Autopush Server (Stored for delivery to client at a later time) 413 | return true; 414 | default: 415 | return false; 416 | } 417 | } 418 | #endregion 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushServiceClientException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http.Headers; 4 | 5 | namespace Lib.Net.Http.WebPush 6 | { 7 | /// 8 | /// An exception representing requesting delivery failure based on push service response. 9 | /// 10 | public class PushServiceClientException : Exception 11 | { 12 | #region Properties 13 | /// 14 | /// Gets the status code of the push service response. 15 | /// 16 | public HttpStatusCode StatusCode { get; } 17 | 18 | /// 19 | /// Gets the headers of the push service response. 20 | /// 21 | public HttpResponseHeaders Headers { get; } 22 | 23 | /// 24 | /// Gets the body of the push service response. 25 | /// 26 | public string Body { get; } 27 | 28 | /// 29 | /// Gets the that initiated the push service request. 30 | /// 31 | public PushSubscription PushSubscription { get; } 32 | #endregion 33 | 34 | #region Constructors 35 | /// 36 | /// Creates new instance of class. 37 | /// 38 | /// The message that describes the current exception. 39 | /// The status code of the push service response. 40 | public PushServiceClientException(string message, HttpStatusCode statusCode) 41 | : this(message, statusCode, null, null, null) 42 | { 43 | } 44 | 45 | /// 46 | /// Creates new instance of class. 47 | /// 48 | /// The message that describes the current exception. 49 | /// The status code of the push service response. 50 | /// The headers of the push service response. 51 | /// The body of the push service response. 52 | /// The that initiated the push service request. 53 | public PushServiceClientException(string message, HttpStatusCode statusCode, HttpResponseHeaders headers, string body, PushSubscription pushSubscription) 54 | : base(message) 55 | { 56 | StatusCode = statusCode; 57 | Headers = headers; 58 | Body = body; 59 | PushSubscription = pushSubscription; 60 | } 61 | #endregion 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Lib.Net.Http.WebPush/PushSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Collections.Generic; 4 | 5 | namespace Lib.Net.Http.WebPush 6 | { 7 | /// 8 | /// Class representing a push subscription 9 | /// 10 | public class PushSubscription 11 | { 12 | #region Properties 13 | /// 14 | /// Gets or sets the subscription endpoint. 15 | /// 16 | public string Endpoint { get; set; } 17 | 18 | /// 19 | /// Gets or sets client keys shared as part of subscription. 20 | /// 21 | public IDictionary Keys { get; set; } 22 | #endregion 23 | 24 | #region Methods 25 | /// 26 | /// Gets specific client key shared as part of subscription. 27 | /// 28 | /// The key name. 29 | /// The key. 30 | public string GetKey(PushEncryptionKeyName keyName) 31 | { 32 | string key = null; 33 | 34 | if (Keys != null) 35 | { 36 | string keyNameStringified = StringifyKeyName(keyName); 37 | 38 | if (Keys.ContainsKey(keyNameStringified)) 39 | { 40 | key = Keys[keyNameStringified]; 41 | } 42 | else 43 | { 44 | key = Keys.SingleOrDefault(x => String.Equals(x.Key, keyNameStringified, StringComparison.OrdinalIgnoreCase)).Value; 45 | } 46 | } 47 | 48 | return key; 49 | } 50 | 51 | /// 52 | /// Sets specific client key shared as part of subscription. 53 | /// 54 | /// The key name. 55 | /// The key. 56 | public void SetKey(PushEncryptionKeyName keyName, string key) 57 | { 58 | if (Keys == null) 59 | { 60 | Keys = new Dictionary(); 61 | } 62 | 63 | Keys[StringifyKeyName(keyName)] = key; 64 | } 65 | 66 | private string StringifyKeyName(PushEncryptionKeyName keyName) 67 | { 68 | return keyName.ToString().ToLowerInvariant(); 69 | } 70 | #endregion 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/Test.Lib.Net.Http.WebPush/Functional/Infrastructure/FakePushServiceApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using Microsoft.AspNetCore; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.AspNetCore.Mvc.Testing; 5 | 6 | namespace Test.Lib.Net.Http.WebPush.Functional.Infrastructure 7 | { 8 | public class FakePushServiceApplicationFactory : WebApplicationFactory 9 | { 10 | protected override IWebHostBuilder CreateWebHostBuilder() 11 | { 12 | return WebHost.CreateDefaultBuilder() 13 | .UseStartup(); 14 | } 15 | 16 | protected override void ConfigureWebHost(IWebHostBuilder builder) 17 | { 18 | builder.UseContentRoot(Path.GetTempPath()); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/Test.Lib.Net.Http.WebPush/Functional/Infrastructure/FakePushServiceStartup.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.AspNetCore.Http.Features; 6 | 7 | namespace Test.Lib.Net.Http.WebPush.Functional.Infrastructure 8 | { 9 | public class FakePushServiceStartup 10 | { 11 | #region Fields 12 | public const int OTHER_CLIENT_ERROR_STATUS_CODE = 499; 13 | public const string OTHER_CLIENT_ERROR_REASON_PHRASE = "Other Client Error"; 14 | public const string OTHER_CLIENT_ERROR_BODY = "{\"code\": 499,\"errno\": 199,\"error\": \"Other Client Error\",\"message\": \"Some other client error occured\"}"; 15 | 16 | private bool shouldRetryAfter = true; 17 | #endregion 18 | 19 | public void ConfigureServices(IServiceCollection services) 20 | { } 21 | 22 | public void Configure(IApplicationBuilder app) 23 | { 24 | app.UseRouting() 25 | .UseEndpoints(endpoints => 26 | { 27 | endpoints.MapPost("/push-rfc-8030-created", context => 28 | { 29 | context.Response.StatusCode = StatusCodes.Status201Created; 30 | 31 | return Task.CompletedTask; 32 | }); 33 | 34 | endpoints.MapPost("/mozilla-autopush-delivered", context => 35 | { 36 | context.Response.StatusCode = StatusCodes.Status200OK; 37 | 38 | return Task.CompletedTask; 39 | }); 40 | 41 | endpoints.MapPost("/mozilla-autopush-stored", context => 42 | { 43 | context.Response.StatusCode = StatusCodes.Status202Accepted; 44 | 45 | return Task.CompletedTask; 46 | }); 47 | 48 | endpoints.MapPost("/push-retry-after-once", context => 49 | { 50 | if (shouldRetryAfter) 51 | { 52 | context.Response.StatusCode = 429; 53 | context.Response.Headers.Add("Retry-After", "5"); 54 | 55 | shouldRetryAfter = false; 56 | } 57 | else 58 | { 59 | context.Response.StatusCode = StatusCodes.Status201Created; 60 | 61 | shouldRetryAfter = true; 62 | } 63 | 64 | return Task.CompletedTask; 65 | }); 66 | 67 | endpoints.MapPost("/push-retry-after-always", context => 68 | { 69 | context.Response.StatusCode = 429; 70 | context.Response.Headers.Add("Retry-After", "5"); 71 | 72 | return Task.CompletedTask; 73 | }); 74 | 75 | endpoints.MapPost("/push-client-error", async context => 76 | { 77 | context.Response.StatusCode = OTHER_CLIENT_ERROR_STATUS_CODE; 78 | context.Response.HttpContext.Features.Get().ReasonPhrase = OTHER_CLIENT_ERROR_REASON_PHRASE; 79 | await context.Response.WriteAsync(OTHER_CLIENT_ERROR_BODY); 80 | }); 81 | }); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/Test.Lib.Net.Http.WebPush/Functional/PushMessageDeliveryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using System.Collections.Generic; 4 | using Xunit; 5 | using Lib.Net.Http.WebPush; 6 | using Lib.Net.Http.WebPush.Authentication; 7 | using Test.Lib.Net.Http.WebPush.Functional.Infrastructure; 8 | 9 | namespace Test.Lib.Net.Http.WebPush.Functional 10 | { 11 | public class PushMessageDeliveryTests : IClassFixture 12 | { 13 | #region Fields 14 | private const string RFC_8030_CREATED_ENDPOINT = "http://localhost/push-rfc-8030-created"; 15 | private const string MOZILLA_AUTOPUSH_DELIVERED_ENDPOINT = "http://localhost/mozilla-autopush-delivered"; 16 | private const string MOZILLA_AUTOPUSH_STORED_ENDPOINT = "http://localhost/mozilla-autopush-stored"; 17 | private const string RETRY_AFTER_ONCE_ENDPOINT = "http://localhost/push-retry-after-once"; 18 | private const string RETRY_AFTER_ALWAYS_ENDPOINT = "http://localhost/push-retry-after-always"; 19 | private const string CLIENT_ERROR_ENDPOINT = "http://localhost/push-client-error"; 20 | 21 | private const string WALRUS_CONTENT = "I am the walrus"; 22 | 23 | private const string PUSH_SUBSCRIPTION_AUTH_KEY = "n5mG_PyMSKALsjsU542E6g"; 24 | private const string PUSH_SUBSCRIPTION_P256DH_KEY = "BDS52l6tfaf6ZEqhyDa0cScvCi4WXNYPIwmfas-7nKLIQex-DVKXB9gUxDExaZEOiwovl6LbWXZBZ9AT-GWT6eQ"; 25 | 26 | private readonly PushSubscription _pushSubscription = new PushSubscription 27 | { 28 | Keys = new Dictionary 29 | { 30 | { "auth", PUSH_SUBSCRIPTION_AUTH_KEY }, 31 | { "p256dh", PUSH_SUBSCRIPTION_P256DH_KEY } 32 | } 33 | }; 34 | 35 | private readonly VapidAuthentication _vapidAuthentication = new VapidAuthentication("BK5sn4jfa0Jqo9MhV01oyzK2FaEHm0KqkSCuUkKr53-9cr-vBE1a9TiiBaWy7hy0eOUF1jhZnwcd3vof4wnwSw0", "AJ2ho7or-6D4StPktpTO3l1ErjHGyxb0jzt9Y8lj67g") 36 | { 37 | Subject = "https://localhost:8080/" 38 | }; 39 | 40 | private readonly FakePushServiceApplicationFactory _pushServiceFactory; 41 | #endregion 42 | 43 | #region Constructor 44 | public PushMessageDeliveryTests(FakePushServiceApplicationFactory pushServiceFactory) 45 | { 46 | _pushServiceFactory = pushServiceFactory; 47 | } 48 | #endregion 49 | 50 | #region Prepare SUT 51 | private PushServiceClient PreparePushServiceClient() 52 | { 53 | return new PushServiceClient(_pushServiceFactory.CreateClient()) 54 | { 55 | DefaultAuthentication = _vapidAuthentication 56 | }; 57 | } 58 | #endregion 59 | 60 | #region Tests 61 | [Theory] 62 | [InlineData(RFC_8030_CREATED_ENDPOINT)] 63 | [InlineData(MOZILLA_AUTOPUSH_DELIVERED_ENDPOINT)] 64 | [InlineData(MOZILLA_AUTOPUSH_STORED_ENDPOINT)] 65 | public async Task PushService_NoError_DeliversPushMessage(string endpoint) 66 | { 67 | _pushSubscription.Endpoint = endpoint; 68 | 69 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 70 | 71 | PushServiceClient pushClient = PreparePushServiceClient(); 72 | 73 | Exception pushMessageDeliveryException = await Record.ExceptionAsync(async () => 74 | { 75 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 76 | }); 77 | 78 | Assert.Null(pushMessageDeliveryException); 79 | } 80 | 81 | [Fact] 82 | public async Task PushService_TooManyRequests_DeliversPushMessageWithRetryAfter() 83 | { 84 | _pushSubscription.Endpoint = RETRY_AFTER_ONCE_ENDPOINT; 85 | 86 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 87 | 88 | PushServiceClient pushClient = PreparePushServiceClient(); 89 | 90 | Exception pushMessageDeliveryException = await Record.ExceptionAsync(async () => 91 | { 92 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 93 | }); 94 | 95 | Assert.Null(pushMessageDeliveryException); 96 | } 97 | 98 | [Fact] 99 | public async Task PushService_TooManyRequests_MaxRetriesAfter_ThrowsPushServiceClientException() 100 | { 101 | _pushSubscription.Endpoint = RETRY_AFTER_ALWAYS_ENDPOINT; 102 | 103 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 104 | 105 | PushServiceClient pushClient = PreparePushServiceClient(); 106 | pushClient.MaxRetriesAfter = 1; 107 | 108 | await Assert.ThrowsAsync(async () => 109 | { 110 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 111 | }); 112 | } 113 | 114 | [Fact] 115 | public async Task PushService_TooManyRequests_MaxRetriesAfter_PushServiceClientExceptionStatusCodeIs429() 116 | { 117 | _pushSubscription.Endpoint = RETRY_AFTER_ALWAYS_ENDPOINT; 118 | 119 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 120 | 121 | PushServiceClient pushClient = PreparePushServiceClient(); 122 | pushClient.MaxRetriesAfter = 1; 123 | 124 | PushServiceClientException pushMessageDeliveryException = await Record.ExceptionAsync(async () => 125 | { 126 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 127 | }) as PushServiceClientException; 128 | 129 | Assert.Equal(429, (int)pushMessageDeliveryException.StatusCode); 130 | } 131 | 132 | [Fact] 133 | public async Task PushService_OtherClientError_ThrowsPushServiceClientException() 134 | { 135 | _pushSubscription.Endpoint = CLIENT_ERROR_ENDPOINT; 136 | 137 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 138 | 139 | PushServiceClient pushClient = PreparePushServiceClient(); 140 | 141 | await Assert.ThrowsAsync(async () => 142 | { 143 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 144 | }); 145 | } 146 | 147 | [Fact] 148 | public async Task PushService_OtherClientError_PushServiceClientExceptionContainsResponseStatusCode() 149 | { 150 | _pushSubscription.Endpoint = CLIENT_ERROR_ENDPOINT; 151 | 152 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 153 | 154 | PushServiceClient pushClient = PreparePushServiceClient(); 155 | 156 | PushServiceClientException pushMessageDeliveryException = await Record.ExceptionAsync(async () => 157 | { 158 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 159 | }) as PushServiceClientException; 160 | 161 | Assert.Equal(FakePushServiceStartup.OTHER_CLIENT_ERROR_STATUS_CODE, (int)pushMessageDeliveryException.StatusCode); 162 | } 163 | 164 | [Fact] 165 | public async Task PushService_OtherClientError_PushServiceClientExceptionContainsResponseReasonPhrase() 166 | { 167 | _pushSubscription.Endpoint = CLIENT_ERROR_ENDPOINT; 168 | 169 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 170 | 171 | PushServiceClient pushClient = PreparePushServiceClient(); 172 | 173 | PushServiceClientException pushMessageDeliveryException = await Record.ExceptionAsync(async () => 174 | { 175 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 176 | }) as PushServiceClientException; 177 | 178 | Assert.Equal(FakePushServiceStartup.OTHER_CLIENT_ERROR_REASON_PHRASE, pushMessageDeliveryException.Message); 179 | } 180 | 181 | [Fact] 182 | public async Task PushService_OtherClientError_PushServiceClientExceptionContainsResponseBody() 183 | { 184 | _pushSubscription.Endpoint = CLIENT_ERROR_ENDPOINT; 185 | 186 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 187 | 188 | PushServiceClient pushClient = PreparePushServiceClient(); 189 | 190 | PushServiceClientException pushMessageDeliveryException = await Record.ExceptionAsync(async () => 191 | { 192 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 193 | }) as PushServiceClientException; 194 | 195 | Assert.Equal(FakePushServiceStartup.OTHER_CLIENT_ERROR_BODY, pushMessageDeliveryException.Body); 196 | } 197 | 198 | [Fact] 199 | public async Task PushService_OtherClientError_PushServiceClientExceptionContainsPushSubscription() 200 | { 201 | _pushSubscription.Endpoint = CLIENT_ERROR_ENDPOINT; 202 | 203 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 204 | 205 | PushServiceClient pushClient = PreparePushServiceClient(); 206 | 207 | PushServiceClientException pushMessageDeliveryException = await Record.ExceptionAsync(async () => 208 | { 209 | await pushClient.RequestPushMessageDeliveryAsync(_pushSubscription, pushMessage); 210 | }) as PushServiceClientException; 211 | 212 | Assert.Equal(_pushSubscription, pushMessageDeliveryException.PushSubscription); 213 | } 214 | 215 | [Fact] 216 | public async Task PushService_PushEncryptionKeysNamesLowercase_DeliversPushMessage() 217 | { 218 | PushSubscription pushSubscription = new PushSubscription 219 | { 220 | Keys = new Dictionary 221 | { 222 | { "auth", PUSH_SUBSCRIPTION_AUTH_KEY }, 223 | { "p256dh", PUSH_SUBSCRIPTION_P256DH_KEY } 224 | }, 225 | Endpoint = RFC_8030_CREATED_ENDPOINT 226 | }; 227 | 228 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 229 | 230 | PushServiceClient pushClient = PreparePushServiceClient(); 231 | 232 | Exception pushMessageDeliveryException = await Record.ExceptionAsync(async () => 233 | { 234 | await pushClient.RequestPushMessageDeliveryAsync(pushSubscription, pushMessage); 235 | }); 236 | 237 | Assert.Null(pushMessageDeliveryException); 238 | } 239 | 240 | [Fact] 241 | public async Task PushService_PushEncryptionKeysNamesUppercase_DeliversPushMessage() 242 | { 243 | PushSubscription pushSubscription = new PushSubscription 244 | { 245 | Keys = new Dictionary 246 | { 247 | { "AUTH", PUSH_SUBSCRIPTION_AUTH_KEY }, 248 | { "P256DH", PUSH_SUBSCRIPTION_P256DH_KEY } 249 | }, 250 | Endpoint = RFC_8030_CREATED_ENDPOINT 251 | }; 252 | 253 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 254 | 255 | PushServiceClient pushClient = PreparePushServiceClient(); 256 | 257 | Exception pushMessageDeliveryException = await Record.ExceptionAsync(async () => 258 | { 259 | await pushClient.RequestPushMessageDeliveryAsync(pushSubscription, pushMessage); 260 | }); 261 | 262 | Assert.Null(pushMessageDeliveryException); 263 | } 264 | 265 | [Fact] 266 | public async Task PushService_PushEncryptionKeysNamesMixedCase_DeliversPushMessage() 267 | { 268 | PushSubscription pushSubscription = new PushSubscription 269 | { 270 | Keys = new Dictionary 271 | { 272 | { "AuTh", PUSH_SUBSCRIPTION_AUTH_KEY }, 273 | { "P256dH", PUSH_SUBSCRIPTION_P256DH_KEY } 274 | }, 275 | Endpoint = RFC_8030_CREATED_ENDPOINT 276 | }; 277 | 278 | PushMessage pushMessage = new PushMessage(WALRUS_CONTENT); 279 | 280 | PushServiceClient pushClient = PreparePushServiceClient(); 281 | 282 | Exception pushMessageDeliveryException = await Record.ExceptionAsync(async () => 283 | { 284 | await pushClient.RequestPushMessageDeliveryAsync(pushSubscription, pushMessage); 285 | }); 286 | 287 | Assert.Null(pushMessageDeliveryException); 288 | } 289 | #endregion 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /test/Test.Lib.Net.Http.WebPush/Test.Lib.Net.Http.WebPush.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | netcoreapp3.1;net8.0;net9.0 4 | false 5 | 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/Test.Lib.Net.Http.WebPush/Unit/PushSubscriptionTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Xunit; 3 | using Lib.Net.Http.WebPush; 4 | 5 | namespace Test.Lib.Net.Http.WebPush.Unit 6 | { 7 | public class PushSubscriptionTests 8 | { 9 | #region Fields 10 | private const string AUTH_KEY = "n5mG_PyMSKALsjsU542E6g"; 11 | private const string P256DH_KEY = "BDS52l6tfaf6ZEqhyDa0cScvCi4WXNYPIwmfas-7nKLIQex-DVKXB9gUxDExaZEOiwovl6LbWXZBZ9AT-GWT6eQ"; 12 | #endregion 13 | 14 | #region Tests 15 | [Fact] 16 | public void GetKey_AuthKeyNameLowercase_ReturnsKey() 17 | { 18 | PushSubscription pushSubscription = new PushSubscription 19 | { 20 | Keys = new Dictionary 21 | { 22 | { "auth", AUTH_KEY } 23 | } 24 | }; 25 | 26 | string authKey = pushSubscription.GetKey(PushEncryptionKeyName.Auth); 27 | 28 | Assert.Equal(AUTH_KEY, authKey); 29 | } 30 | 31 | [Fact] 32 | public void GetKey_AuthKeyNameUppercase_ReturnsKey() 33 | { 34 | PushSubscription pushSubscription = new PushSubscription 35 | { 36 | Keys = new Dictionary 37 | { 38 | { "AUTH", AUTH_KEY } 39 | } 40 | }; 41 | 42 | string authKey = pushSubscription.GetKey(PushEncryptionKeyName.Auth); 43 | 44 | Assert.Equal(AUTH_KEY, authKey); 45 | } 46 | 47 | [Fact] 48 | public void GetKey_AuthKeyNameMixedCase_ReturnsKey() 49 | { 50 | PushSubscription pushSubscription = new PushSubscription 51 | { 52 | Keys = new Dictionary 53 | { 54 | { "AuTh", AUTH_KEY } 55 | } 56 | }; 57 | 58 | string authKey = pushSubscription.GetKey(PushEncryptionKeyName.Auth); 59 | 60 | Assert.Equal(AUTH_KEY, authKey); 61 | } 62 | 63 | [Fact] 64 | public void GetKey_P256DHKeyNameLowercase_ReturnsKey() 65 | { 66 | PushSubscription pushSubscription = new PushSubscription 67 | { 68 | Keys = new Dictionary 69 | { 70 | { "p256dh", P256DH_KEY } 71 | } 72 | }; 73 | 74 | string p256dhKey = pushSubscription.GetKey(PushEncryptionKeyName.P256DH); 75 | 76 | Assert.Equal(P256DH_KEY, p256dhKey); 77 | } 78 | 79 | [Fact] 80 | public void GetKey_P256DHKeyNameUppercase_ReturnsKey() 81 | { 82 | PushSubscription pushSubscription = new PushSubscription 83 | { 84 | Keys = new Dictionary 85 | { 86 | { "P256DH", P256DH_KEY } 87 | } 88 | }; 89 | 90 | string p256dhKey = pushSubscription.GetKey(PushEncryptionKeyName.P256DH); 91 | 92 | Assert.Equal(P256DH_KEY, p256dhKey); 93 | } 94 | 95 | [Fact] 96 | public void GetKey_P256DHKeyNameMixedCase_ReturnsKey() 97 | { 98 | PushSubscription pushSubscription = new PushSubscription 99 | { 100 | Keys = new Dictionary 101 | { 102 | { "P256dH", P256DH_KEY } 103 | } 104 | }; 105 | 106 | string p256dhKey = pushSubscription.GetKey(PushEncryptionKeyName.P256DH); 107 | 108 | Assert.Equal(P256DH_KEY, p256dhKey); 109 | } 110 | #endregion 111 | } 112 | } 113 | --------------------------------------------------------------------------------