diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..1ea2594
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "NuGetKeyVaultSignTool": {
+ "version": "3.2.3",
+ "commands": [
+ "NuGetKeyVaultSignTool"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
index f536386..7fa52f8 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -4,6 +4,8 @@ root=true
# C# files
[*.cs]
+file_header_template=Copyright (c) Duende Software. All rights reserved.\nSee LICENSE in the project root for license information.
+
#### Core EditorConfig Options ####
# Indentation and spacing
diff --git a/.github/workflows/SectigoPublicCodeSigningRootCrossAAA.crt b/.github/workflows/SectigoPublicCodeSigningRootCrossAAA.crt
new file mode 100644
index 0000000..c2f2350
--- /dev/null
+++ b/.github/workflows/SectigoPublicCodeSigningRootCrossAAA.crt
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFbzCCBFegAwIBAgIQSPyTtGBVlI02p8mKidaUFjANBgkqhkiG9w0BAQwFADB7
+MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
+VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
+AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTIxMDUyNTAwMDAwMFoXDTI4
+MTIzMTIzNTk1OVowVjELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGlt
+aXRlZDEtMCsGA1UEAxMkU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWduaW5nIFJvb3Qg
+UjQ2MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAjeeUEiIEJHQu/xYj
+ApKKtq42haxH1CORKz7cfeIxoFFvrISR41KKteKW3tCHYySJiv/vEpM7fbu2ir29
+BX8nm2tl06UMabG8STma8W1uquSggyfamg0rUOlLW7O4ZDakfko9qXGrYbNzszwL
+DO/bM1flvjQ345cbXf0fEj2CA3bm+z9m0pQxafptszSswXp43JJQ8mTHqi0Eq8Nq
+6uAvp6fcbtfo/9ohq0C/ue4NnsbZnpnvxt4fqQx2sycgoda6/YDnAdLv64IplXCN
+/7sVz/7RDzaiLk8ykHRGa0c1E3cFM09jLrgt4b9lpwRrGNhx+swI8m2JmRCxrds+
+LOSqGLDGBwF1Z95t6WNjHjZ/aYm+qkU+blpfj6Fby50whjDoA7NAxg0POM1nqFOI
++rgwZfpvx+cdsYN0aT6sxGg7seZnM5q2COCABUhA7vaCZEao9XOwBpXybGWfv1Vb
+HJxXGsd4RnxwqpQbghesh+m2yQ6BHEDWFhcp/FycGCvqRfXvvdVnTyheBe6QTHrn
+xvTQ/PrNPjJGEyA2igTqt6oHRpwNkzoJZplYXCmjuQymMDg80EY2NXycuu7D1fkK
+dvp+BRtAypI16dV60bV/AK6pkKrFfwGcELEW/MxuGNxvYv6mUKe4e7idFT/+IAx1
+yCJaE5UZkADpGtXChvHjjuxf9OUCAwEAAaOCARIwggEOMB8GA1UdIwQYMBaAFKAR
+CiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQy65Ka/zWWSC8oQEJwIDaRXBeF
+5jAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zATBgNVHSUEDDAKBggr
+BgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEMGA1UdHwQ8MDow
+OKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmljYXRlU2Vy
+dmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29j
+c3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBDAUAA4IBAQASv6Hvi3SamES4aUa1
+qyQKDKSKZ7g6gb9Fin1SB6iNH04hhTmja14tIIa/ELiueTtTzbT72ES+BtlcY2fU
+QBaHRIZyKtYyFfUSg8L54V0RQGf2QidyxSPiAjgaTCDi2wH3zUZPJqJ8ZsBRNraJ
+AlTH/Fj7bADu/pimLpWhDFMpH2/YGaZPnvesCepdgsaLr4CnvYFIUoQx2jLsFeSm
+TD1sOXPUC4U5IOCFGmjhp0g4qdE2JXfBjRkWxYhMZn0vY86Y6GnfrDyoXZ3JHFuu
+2PMvdM+4fvbXg50RlmKarkUT2n/cR/vfw1Kf5gZV6Z2M8jpiUbzsJA8p1FiAhORF
+e1rY
+-----END CERTIFICATE-----
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ea37811
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,99 @@
+name: ci
+
+permissions:
+ contents: read
+ checks: write
+ packages: write
+
+on:
+ workflow_dispatch:
+ push:
+ pull_request:
+
+env:
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+
+ - name: Build
+ run: dotnet build -c Release AspNetCore.sln
+
+ - name: Test
+ run: |
+ dotnet test -c Release test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj \
+ --logger "console;verbosity=normal" \
+ --logger "trx;LogFileName=Tests.trx" \
+ --collect:"XPlat Code Coverage"
+
+ - name: Test report
+ id: test-report
+ uses: dorny/test-reporter@v1
+ if: success() || failure() # run this step even if previous step failed
+ with:
+ name: Test results
+ path: test/AspNetCore.Authentication.JwtBearer.Tests/TestResults/Tests.trx
+ reporter: dotnet-trx
+ fail-on-error: true
+ fail-on-empty: true
+
+ - name: Pack
+ run: |
+ dotnet pack -c Release \
+ src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj \
+ --no-build \
+ -o artifacts
+
+ - name: Sign
+ if: (github.ref == 'refs/heads/main')
+ run: |
+ echo "Install Sectigo CodeSiging CA certificates"
+ sudo apt-get update
+ sudo apt-get install -y ca-certificates
+ sudo cp .github/workflows/SectigoPublicCodeSigningRootCrossAAA.crt /usr/local/share/ca-certificates/
+ sudo update-ca-certificates
+ echo "Restore tools"
+ dotnet tool restore
+ echo "Sign"
+ for file in artifacts/*.nupkg; do
+ dotnet NuGetKeyVaultSignTool sign "$file" \
+ --file-digest sha256 \
+ --timestamp-rfc3161 http://timestamp.digicert.com \
+ --azure-key-vault-url https://duendecodesigning.vault.azure.net/ \
+ --azure-key-vault-client-id 18e3de68-2556-4345-8076-a46fad79e474 \
+ --azure-key-vault-tenant-id ed3089f0-5401-4758-90eb-066124e2d907 \
+ --azure-key-vault-client-secret ${{ secrets.SignClientSecret }} \
+ --azure-key-vault-certificate CodeSigning
+ done
+
+ - name: Push packages
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ if: (github.ref == 'refs/heads/main')
+ run: |
+ dotnet nuget push artifacts/*.nupkg -s https://www.myget.org/F/duende_identityserver/api/v2/package -k ${{ secrets.MYGET }} --skip-duplicate
+ dotnet nuget push artifacts/*.nupkg --source https://nuget.pkg.github.com/DuendeSoftware/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ if: (github.ref == 'refs/heads/main')
+ with:
+ path: artifacts/*.nupkg
+ compression-level: 0
+ overwrite: true
+ retention-days: 15
\ No newline at end of file
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..8f7d362
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,35 @@
+name: codeql
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ schedule:
+ - cron: '38 15 * * 0'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: csharp
+
+ - name: Auto build
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:csharp"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..e561ff0
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,107 @@
+name: release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ type: string
+ description: "Version in format X.Y.Z or X.Y.Z-preview.N"
+ required: true
+ default: '0.0.0'
+
+env:
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+
+jobs:
+ tag:
+ name: Tag and Pack
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ packages: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+
+ - name: Tag
+ run: |
+ git config --global user.email "github-bot@duendesoftware.com"
+ git config --global user.name "Duende Software GitHub Bot"
+ git tag -a anc-${{ github.event.inputs.version }} -m "Release v${{ github.event.inputs.version }}"
+ git push origin anc-${{ github.event.inputs.version }}
+
+ - name: Pack
+ run: dotnet pack -c Release src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj -o artifacts
+
+ - name: Sign
+ if: (github.ref == 'refs/heads/main')
+ run: |
+ echo "Install Sectigo CodeSiging CA certificates"
+ sudo apt-get update
+ sudo apt-get install -y ca-certificates
+ sudo cp .github/workflows/SectigoPublicCodeSigningRootCrossAAA.crt /usr/local/share/ca-certificates/
+ sudo update-ca-certificates
+ echo "Restore tools"
+ dotnet tool restore
+ echo "Sign"
+ for file in artifacts/*.nupkg; do
+ dotnet NuGetKeyVaultSignTool sign "$file" \
+ --file-digest sha256 \
+ --timestamp-rfc3161 http://timestamp.digicert.com \
+ --azure-key-vault-url https://duendecodesigning.vault.azure.net/ \
+ --azure-key-vault-client-id 18e3de68-2556-4345-8076-a46fad79e474 \
+ --azure-key-vault-tenant-id ed3089f0-5401-4758-90eb-066124e2d907 \
+ --azure-key-vault-client-secret ${{ secrets.SignClientSecret }} \
+ --azure-key-vault-certificate CodeSigning
+ done
+
+ - name: Push packages to MyGet
+ run: dotnet nuget push artifacts\*.nupkg -s https://www.myget.org/F/duende_identityserver/api/v2/package -k ${{ secrets.MYGET }} --skip-duplicate
+
+ - name: Push NuGet package to GitHub Packages
+ run: dotnet nuget push artifacts\*.nupkg --source https://nuget.pkg.github.com/DuendeSoftware/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ if: (github.ref == 'refs/heads/main')
+ with:
+ path: artifacts/*.nupkg
+ compression-level: 0
+ overwrite: true
+ retention-days: 15
+
+ publish:
+ name: Publish to NuGet
+ runs-on: ubuntu-latest
+ environment: nuget.org
+ needs: tag
+
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: ignore-this-artifacts
+ path: artifacts
+
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+
+ - name: List files
+ shell: bash
+ run: tree
+
+ - name: Push to nuget.org
+ run: dotnet nuget push artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_ORG_API_KEY }} --skip-duplicate
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 072ea96..58bbcca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -215,3 +215,6 @@ tempkey.jwk
keys
*.key
test/Configuration.IntegrationTests/CoverageReports
+
+# Build artifacts
+artifacts/*
diff --git a/AspNetCore.sln b/AspNetCore.sln
new file mode 100644
index 0000000..56708cb
--- /dev/null
+++ b/AspNetCore.sln
@@ -0,0 +1,46 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{BB64AC83-C298-4841-9012-AA371FC4607C}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2F0844C7-7AAC-4E3F-90E0-3FAC41A5109D}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFramework", "test\TestFramework\TestFramework.csproj", "{9B2B340B-16D2-4936-947D-5D05FC0E4F1D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Authentication.JwtBearer", "src\AspNetCore.Authentication.JwtBearer\AspNetCore.Authentication.JwtBearer.csproj", "{D5403282-86CB-4617-8908-4EB0FAAD35DC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCore.Authentication.JwtBearer.Tests", "test\AspNetCore.Authentication.JwtBearer.Tests\AspNetCore.Authentication.JwtBearer.Tests.csproj", "{3A853DB3-E36E-4B19-94B5-8A01569CA06C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {9B2B340B-16D2-4936-947D-5D05FC0E4F1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B2B340B-16D2-4936-947D-5D05FC0E4F1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B2B340B-16D2-4936-947D-5D05FC0E4F1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B2B340B-16D2-4936-947D-5D05FC0E4F1D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D5403282-86CB-4617-8908-4EB0FAAD35DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D5403282-86CB-4617-8908-4EB0FAAD35DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D5403282-86CB-4617-8908-4EB0FAAD35DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D5403282-86CB-4617-8908-4EB0FAAD35DC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A853DB3-E36E-4B19-94B5-8A01569CA06C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A853DB3-E36E-4B19-94B5-8A01569CA06C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A853DB3-E36E-4B19-94B5-8A01569CA06C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A853DB3-E36E-4B19-94B5-8A01569CA06C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {9B2B340B-16D2-4936-947D-5D05FC0E4F1D} = {BB64AC83-C298-4841-9012-AA371FC4607C}
+ {D5403282-86CB-4617-8908-4EB0FAAD35DC} = {2F0844C7-7AAC-4E3F-90E0-3FAC41A5109D}
+ {3A853DB3-E36E-4B19-94B5-8A01569CA06C} = {BB64AC83-C298-4841-9012-AA371FC4607C}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {E214C8C0-C8B4-4503-9CBD-03B8A0D4B5D9}
+ EndGlobalSection
+EndGlobal
diff --git a/AspNetCore.v3.ncrunchsolution b/AspNetCore.v3.ncrunchsolution
new file mode 100644
index 0000000..13107d3
--- /dev/null
+++ b/AspNetCore.v3.ncrunchsolution
@@ -0,0 +1,8 @@
+
+
+ True
+ True
+ True
+ True
+
+
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..9ad06a0
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,38 @@
+
+
+
+ 8.0.1
+ 8.0.0
+ 7.1.2
+
+
+
+ 9.0.0-rc.2.24474.3
+ 9.0.0-rc.2.24473.5
+ 8.0.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3fdf07c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,5 @@
+By accessing the Duende IdentityServer code here, you are agreeing to the following licensing terms:
+
+https://duendesoftware.com/license
+
+If you do not agree to these terms, do not access the Duende IdentityServer code.
diff --git a/README.md b/README.md
index 173e556..8c19178 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,7 @@
-# Clients
-Tools for building OAuth and OIDC clients of IdentityServer in ASP.NET.
+# Duende Extensions for ASP.NET
+
+Extensions for ASP.NET to help with leveraging advanced features of Duende IdentityServer
+
+### Extensions for the JwtBearer authentication handler
+
+* support for DPoP
diff --git a/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj b/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj
new file mode 100644
index 0000000..34eea9b
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/AspNetCore.Authentication.JwtBearer.csproj
@@ -0,0 +1,34 @@
+
+
+ net8.0;net9.0
+ enable
+ enable
+ Duende.AspNetCore.Authentication.JwtBearer
+ Duende.AspNetCore.Authentication.JwtBearer
+ Duende.AspnetCore.Authentication.JwtBearer
+ true
+
+ NU1507
+ true
+
+
+ 0.1
+ anc-
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/ConfigureJwtBearerOptions.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/ConfigureJwtBearerOptions.cs
new file mode 100644
index 0000000..ebe86c3
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/ConfigureJwtBearerOptions.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Options;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Ensures that the are configured with .
+///
+public sealed class ConfigureJwtBearerOptions : IPostConfigureOptions
+{
+ private readonly string _configScheme;
+
+ ///
+ /// Constructs a new instance of that will operate on the specified scheme name.
+ ///
+ public ConfigureJwtBearerOptions(string configScheme)
+ {
+ _configScheme = configScheme;
+ }
+
+ ///
+ public void PostConfigure(string? name, JwtBearerOptions options)
+ {
+ if (_configScheme == name)
+ {
+ if (options.EventsType != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.EventsType))
+ {
+ throw new Exception("EventsType on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support.");
+ }
+ if (options.Events != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.Events.GetType()))
+ {
+ throw new Exception("Events on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support.");
+ }
+
+ if (options.Events == null && options.EventsType == null)
+ {
+ options.EventsType = typeof(DPoPJwtBearerEvents);
+ }
+ }
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPExtensions.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPExtensions.cs
new file mode 100644
index 0000000..2c73a85
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPExtensions.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using IdentityModel;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Extensions methods for DPoP
+///
+static class DPoPExtensions
+{
+ public static string? GetAuthorizationScheme(this HttpRequest request)
+ {
+ return request.Headers.Authorization.FirstOrDefault()?.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)[0];
+ }
+
+ public static string? GetDPoPProofToken(this HttpRequest request)
+ {
+ return request.Headers[OidcConstants.HttpHeaders.DPoP].FirstOrDefault();
+ }
+
+ public static string? GetDPoPNonce(this AuthenticationProperties props)
+ {
+ if (props.Items.ContainsKey("DPoP-Nonce"))
+ {
+ return props.Items["DPoP-Nonce"];
+ }
+ return null;
+ }
+ public static void SetDPoPNonce(this AuthenticationProperties props, string nonce)
+ {
+ props.Items["DPoP-Nonce"] = nonce;
+ }
+
+ ///
+ /// Create the value of a thumbprint-based cnf claim
+ ///
+ public static string CreateThumbprintCnf(this JsonWebKey jwk)
+ {
+ var jkt = jwk.CreateThumbprint();
+ var values = new Dictionary
+ {
+ { JwtClaimTypes.ConfirmationMethods.JwkThumbprint, jkt }
+ };
+ return JsonSerializer.Serialize(values);
+ }
+
+ ///
+ /// Create the value of a thumbprint
+ ///
+ public static string CreateThumbprint(this JsonWebKey jwk)
+ {
+ return Base64Url.Encode(jwk.ComputeJwkThumbprint());
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPJwtBearerEvents.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPJwtBearerEvents.cs
new file mode 100644
index 0000000..0f1f7b8
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPJwtBearerEvents.cs
@@ -0,0 +1,222 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using IdentityModel;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.Net.Http.Headers;
+using static IdentityModel.OidcConstants;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Events for the Jwt Bearer authentication handler that enable DPoP.
+///
+public class DPoPJwtBearerEvents : JwtBearerEvents
+{
+ private readonly IOptionsMonitor _optionsMonitor;
+ private readonly IDPoPProofValidator _validator;
+ private readonly ILogger _logger;
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ ///
+ ///
+ ///
+ public DPoPJwtBearerEvents(IOptionsMonitor optionsMonitor, IDPoPProofValidator validator, ILogger logger)
+ {
+ _optionsMonitor = optionsMonitor;
+ _validator = validator;
+ _logger = logger;
+ }
+
+ ///
+ /// Attempts to retrieve a DPoP access token from incoming requests, and
+ /// optionally enforces its presence.
+ ///
+ public override Task MessageReceived(MessageReceivedContext context)
+ {
+ var dpopOptions = _optionsMonitor.Get(context.Scheme.Name);
+
+ if (TryGetDPoPAccessToken(context.HttpContext.Request, dpopOptions.ProofTokenMaxLength, out var token))
+ {
+ context.Token = token;
+ }
+ else if (dpopOptions.TokenMode == DPoPMode.DPoPOnly)
+ {
+ // this rejects the attempt for this handler,
+ // since we don't want to attempt Bearer given the Mode
+ context.NoResult();
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Ensures that a valid DPoP proof proof accompanies DPoP access tokens.
+ ///
+ public override async Task TokenValidated(TokenValidatedContext context)
+ {
+ var dPoPOptions = _optionsMonitor.Get(context.Scheme.Name);
+
+ if (TryGetDPoPAccessToken(context.HttpContext.Request, dPoPOptions.ProofTokenMaxLength, out var at))
+ {
+ var proofToken = context.HttpContext.Request.GetDPoPProofToken();
+ if (proofToken == null)
+ {
+ throw new InvalidOperationException("Missing DPoP (proof token) HTTP header");
+ }
+
+ // TODO - Add support for introspection
+ var handler = new JsonWebTokenHandler();
+ var parsedToken = handler.ReadJsonWebToken(at);
+
+ var result = await _validator.Validate(new DPoPProofValidationContext
+ {
+ Scheme = context.Scheme.Name,
+ ProofToken = proofToken,
+ AccessToken = at,
+ AccessTokenClaims = parsedToken?.Claims ?? [],
+ Method = context.HttpContext.Request.Method,
+ Url = context.HttpContext.Request.Scheme + "://" + context.HttpContext.Request.Host + context.HttpContext.Request.PathBase + context.HttpContext.Request.Path
+ });
+
+ if (result.IsError)
+ {
+ context.Fail(result.ErrorDescription ?? result.Error ?? throw new Exception("No ErrorDescription or Error set."));
+
+ // we need to stash these values away, so they are available later when the Challenge method is called later
+ context.HttpContext.Items["DPoP-Error"] = result.Error;
+ if (!string.IsNullOrWhiteSpace(result.ErrorDescription))
+ {
+ context.HttpContext.Items["DPoP-ErrorDescription"] = result.ErrorDescription;
+ }
+ if (!string.IsNullOrWhiteSpace(result.ServerIssuedNonce))
+ {
+ context.HttpContext.Items["DPoP-Nonce"] = result.ServerIssuedNonce;
+ }
+ }
+ }
+ else if (dPoPOptions.TokenMode == DPoPMode.DPoPAndBearer)
+ {
+ // if the scheme used was not DPoP, then it was Bearer
+ // and if an access token was presented with a cnf, then the
+ // client should have sent it as DPoP, so we fail the request
+ if (context.Principal?.HasClaim(x => x.Type == JwtClaimTypes.Confirmation) ?? false)
+ {
+ context.HttpContext.Items["Bearer-ErrorDescription"] = "Must use DPoP when using an access token with a 'cnf' claim";
+ context.Fail("Must use DPoP when using an access token with a 'cnf' claim");
+ }
+ }
+ }
+
+ const string DPoPPrefix = OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP + " ";
+
+ ///
+ /// Checks if the HTTP authorization header's 'scheme' is DPoP.
+ ///
+ protected static bool IsDPoPAuthorizationScheme(HttpRequest request)
+ {
+ var authz = request.Headers.Authorization.FirstOrDefault();
+ return authz?.StartsWith(DPoPPrefix, StringComparison.Ordinal) == true;
+ }
+
+ ///
+ /// Attempts to retrieve a DPoP access token from an .
+ ///
+ public bool TryGetDPoPAccessToken(HttpRequest request,
+ int maxLength,
+ [NotNullWhen(true)] out string? token)
+ {
+ token = null;
+
+ var authz = request.Headers.Authorization.FirstOrDefault();
+ if (authz != null && authz.Length >= maxLength)
+ {
+ _logger.LogInformation("DPoP proof rejected because it exceeded ProofTokenMaxLength.");
+ return false;
+ }
+ if (authz?.StartsWith(DPoPPrefix, StringComparison.Ordinal) == true)
+ {
+ token = authz[DPoPPrefix.Length..].Trim();
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Adds the necessary HTTP headers and response codes for DPoP error
+ /// handling and nonce generation.
+ ///
+ public override Task Challenge(JwtBearerChallengeContext context)
+ {
+ var dPoPOptions = _optionsMonitor.Get(context.Scheme.Name);
+
+ if (dPoPOptions.TokenMode == DPoPMode.DPoPOnly)
+ {
+ // if we are using DPoP only, then we don't need/want the default
+ // JwtBearerHandler to add its WWW-Authenticate response header,
+ // so we have to set the status code ourselves
+ context.Response.StatusCode = 401;
+ context.HandleResponse();
+ }
+ else if (context.HttpContext.Items.ContainsKey("Bearer-ErrorDescription"))
+ {
+ var description = context.HttpContext.Items["Bearer-ErrorDescription"] as string;
+ context.ErrorDescription = description;
+ }
+
+ if (IsDPoPAuthorizationScheme(context.HttpContext.Request))
+ {
+ // if we are challenging due to dpop, then don't allow bearer www-auth to emit an error
+ context.Error = null;
+ }
+
+ // now we always want to add our WWW-Authenticate for DPoP
+ // For example:
+ // WWW-Authenticate: DPoP error="invalid_dpop_proof", error_description="Invalid 'iat' value."
+ var sb = new StringBuilder();
+ sb.Append(OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP);
+
+ if (context.HttpContext.Items.ContainsKey("DPoP-Error"))
+ {
+ var error = context.HttpContext.Items["DPoP-Error"] as string;
+ sb.Append(" error=\"");
+ sb.Append(error);
+ sb.Append('\"');
+
+ if (context.HttpContext.Items.ContainsKey("DPoP-ErrorDescription"))
+ {
+ var description = context.HttpContext.Items["DPoP-ErrorDescription"] as string;
+
+ sb.Append(", error_description=\"");
+ sb.Append(description);
+ sb.Append('\"');
+ }
+ }
+
+ context.Response.Headers.Append(HeaderNames.WWWAuthenticate, sb.ToString());
+
+ if (context.HttpContext.Items.ContainsKey("DPoP-Nonce"))
+ {
+ var nonce = context.HttpContext.Items["DPoP-Nonce"] as string;
+ context.Response.Headers[HttpHeaders.DPoPNonce] = nonce;
+ }
+ else
+ {
+ var nonce = context.Properties.GetDPoPNonce();
+ if (nonce != null)
+ {
+ context.Response.Headers[HttpHeaders.DPoPNonce] = nonce;
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPMode.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPMode.cs
new file mode 100644
index 0000000..99846e9
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPMode.cs
@@ -0,0 +1,19 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Determines if DPoP and Bearer tokens are allowed, or only DPoP tokens.
+///
+public enum DPoPMode
+{
+ ///
+ /// Only DPoP tokens will be accepted
+ ///
+ DPoPOnly,
+ ///
+ /// Both DPoP and Bearer tokens will be accepted
+ ///
+ DPoPAndBearer
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs
new file mode 100644
index 0000000..4eb57be
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPOptions.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Options for DPoP.
+///
+public class DPoPOptions
+{
+ ///
+ /// Controls if both DPoP and Bearer tokens are allowed, or only DPoP. Defaults to .
+ ///
+ public DPoPMode TokenMode { get; set; } = DPoPMode.DPoPOnly;
+
+ ///
+ /// The amount of time that a proof token is valid for. Defaults to 1 second.
+ ///
+ public TimeSpan ProofTokenValidityDuration { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// The amount of time to add to account for clock skew when checking the
+ /// issued at time supplied by the client in the form of the iat claim in
+ /// the proof token. Defaults to 5 minutes.
+ ///
+ public TimeSpan ClientClockSkew { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// The amount of time to add to account for clock skew when checking the
+ /// issued at time supplied by the server (that is, by this API) in the form
+ /// of a nonce. Defaults to zero.
+ ///
+ public TimeSpan ServerClockSkew { get; set; } = TimeSpan.Zero;
+
+ ///
+ /// Controls how the issued at time of proof tokens is validated. Defaults to .
+ ///
+ public ExpirationValidationMode ValidationMode { get; set; } = ExpirationValidationMode.IssuedAt;
+
+ ///
+ /// The maximum allowed length of a proof token, which is enforced to
+ /// prevent resource-exhaustion attacks. Defaults to 4000 characters.
+ ///
+ public int ProofTokenMaxLength { get; set; } = 4000;
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonContext.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonContext.cs
new file mode 100644
index 0000000..b39358a
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonContext.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Provides contextual information about a DPoP proof during validation.
+///
+public record DPoPProofValidationContext
+{
+ ///
+ /// The ASP.NET Core authentication scheme triggering the validation
+ ///
+ public required string Scheme { get; init; }
+
+ ///
+ /// The HTTP URL to validate
+ ///
+ public required string Url { get; init; }
+
+ ///
+ /// The HTTP method to validate
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// The DPoP proof token to validate
+ ///
+ public required string ProofToken { get; init; }
+
+ ///
+ /// The access token
+ ///
+ public required string AccessToken { get; init; }
+
+ ///
+ /// The claims associated with the access token.
+ /// This is included separately from the because getting the claims
+ /// might be an expensive operation (especially if the token is a reference token).
+ ///
+ public IEnumerable AccessTokenClaims { get; init; } = [];
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonResult.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonResult.cs
new file mode 100644
index 0000000..d2aad83
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPProofValidatonResult.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Describes the result of validating a DPoP Proof.
+///
+public class DPoPProofValidationResult
+{
+ ///
+ /// Indicates if the result was successful or not
+ ///
+ public bool IsError { get; private set; }
+
+ ///
+ /// The error code for the validation result
+ ///
+ public string? Error { get; private set; }
+
+ ///
+ /// The error description code for the validation result
+ ///
+ public string? ErrorDescription { get; private set; }
+
+ ///
+ /// The serialized JWK from the validated DPoP proof token.
+ ///
+ public string? JsonWebKey { get; set; }
+
+ ///
+ /// The JWK thumbprint from the validated DPoP proof token.
+ ///
+ public string? JsonWebKeyThumbprint { get; set; }
+
+ ///
+ /// The cnf value for the DPoP proof token
+ ///
+ public string? Confirmation { get; set; }
+
+ ///
+ /// The payload value of the DPoP proof token.
+ ///
+ public IDictionary? Payload { get; internal set; }
+
+ ///
+ /// The SHA256 hash of the jti value read from the payload.
+ ///
+ public string? TokenIdHash { get; set; }
+
+ ///
+ /// The ath value read from the payload.
+ ///
+ public string? AccessTokenHash { get; set; }
+
+ ///
+ /// The nonce value read from the payload.
+ ///
+ public string? Nonce { get; set; }
+
+ ///
+ /// The iat value read from the payload.
+ ///
+ public long? IssuedAt { get; set; }
+
+ ///
+ /// The nonce value issued by the server.
+ ///
+ public string? ServerIssuedNonce { get; set; }
+
+ ///
+ /// Sets the error properties of the result.
+ ///
+ public void SetError(string description, string message = OidcConstants.TokenErrors.InvalidDPoPProof)
+ {
+ Error = message;
+ ErrorDescription = description;
+ IsError = true;
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs
new file mode 100644
index 0000000..ddec764
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DPoPServiceCollectionExtensions.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Extension methods for setting up DPoP on a JwtBearer authentication scheme.
+///
+public static class DPoPServiceCollectionExtensions
+{
+ ///
+ /// Sets up DPoP on a JwtBearer authentication scheme.
+ ///
+ public static IServiceCollection ConfigureDPoPTokensForScheme(this IServiceCollection services, string scheme)
+ {
+ services.AddOptions();
+
+ services.AddTransient();
+ services.AddTransient();
+ services.AddDistributedMemoryCache();
+ services.AddTransient();
+
+ services.AddSingleton>(new ConfigureJwtBearerOptions(scheme));
+
+ return services;
+ }
+
+ ///
+ /// Sets up DPoP on a JwtBearer authentication scheme, and configures .
+ ///
+ public static IServiceCollection ConfigureDPoPTokensForScheme(this IServiceCollection services, string scheme, Action configure)
+ {
+ services.Configure(scheme, configure);
+ return services.ConfigureDPoPTokensForScheme(scheme);
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultDPoPProofValidator.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultDPoPProofValidator.cs
new file mode 100644
index 0000000..54ea69e
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultDPoPProofValidator.cs
@@ -0,0 +1,555 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using IdentityModel;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Default implementation of IDPoPProofValidator.
+///
+public class DefaultDPoPProofValidator : IDPoPProofValidator
+{
+ const string DataProtectorPurpose = "DPoPJwtBearerEvents-DPoPProofValidation-nonce";
+
+ ///
+ /// The signing algorithms supported for DPoP proofs.
+ ///
+ protected readonly static IEnumerable SupportedSigningAlgorithms =
+ [
+ SecurityAlgorithms.RsaSha256,
+ SecurityAlgorithms.RsaSha384,
+ SecurityAlgorithms.RsaSha512,
+
+ SecurityAlgorithms.RsaSsaPssSha256,
+ SecurityAlgorithms.RsaSsaPssSha384,
+ SecurityAlgorithms.RsaSsaPssSha512,
+
+ SecurityAlgorithms.EcdsaSha256,
+ SecurityAlgorithms.EcdsaSha384,
+ SecurityAlgorithms.EcdsaSha512
+ ];
+
+
+ ///
+ /// Provides the options for DPoP proof validation.
+ ///
+ protected readonly IOptionsMonitor OptionsMonitor;
+
+ ///
+ /// Protects and unprotects nonce values.
+ ///
+ protected readonly IDataProtector DataProtector;
+
+ ///
+ /// Caches proof tokens to detect replay.
+ ///
+ protected readonly IReplayCache ReplayCache;
+
+ ///
+ /// Clock for checking proof expiration.
+ ///
+ protected readonly TimeProvider TimeProvider;
+
+ ///
+ /// The logger.
+ ///
+ protected readonly ILogger Logger;
+
+ ///
+ /// Constructs a new instance of the .
+ ///
+ public DefaultDPoPProofValidator(IOptionsMonitor optionsMonitor,
+ IDataProtectionProvider dataProtectionProvider, IReplayCache replayCache,
+ TimeProvider timeProvider, ILogger logger)
+ {
+ OptionsMonitor = optionsMonitor;
+ DataProtector = dataProtectionProvider.CreateProtector(DataProtectorPurpose);
+ ReplayCache = replayCache;
+ TimeProvider = timeProvider;
+ Logger = logger;
+ }
+
+ ///
+ /// Validates the DPoP proof.
+ ///
+ public async Task Validate(DPoPProofValidationContext context, CancellationToken cancellationToken = default)
+ {
+ var result = new DPoPProofValidationResult();
+
+ if (string.IsNullOrEmpty(context.ProofToken))
+ {
+ result.SetError("Missing DPoP proof value.");
+ return result;
+ }
+
+ await ValidateHeader(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ Logger.LogDebug("Failed to validate DPoP header");
+ return result;
+ }
+
+ await ValidateSignature(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ Logger.LogDebug("Failed to validate DPoP signature");
+ return result;
+ }
+
+ await ValidatePayload(context, result);
+ if (result.IsError)
+ {
+ Logger.LogDebug("Failed to validate DPoP payload");
+ return result;
+ }
+
+ Logger.LogDebug("Successfully validated DPoP proof token");
+ return result;
+ }
+
+ ///
+ /// Validates the header.
+ ///
+ protected virtual Task ValidateHeader(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken cancellationToken = default)
+ {
+ JsonWebToken token;
+
+ var handler = new JsonWebTokenHandler();
+ try
+ {
+ token = handler.ReadJsonWebToken(context.ProofToken);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug("Error parsing DPoP token: {error}", ex.Message);
+ result.SetError("Malformed DPoP token.");
+ return Task.CompletedTask;
+ }
+
+ if (!token.TryGetHeaderValue("typ", out var typ) || typ != JwtClaimTypes.JwtTypes.DPoPProofToken)
+ {
+ Logger.LogDebug("Failed to get typ header");
+ result.SetError("Invalid 'typ' value.");
+ return Task.CompletedTask;
+ }
+
+ if (!token.TryGetHeaderValue("alg", out var alg) || !SupportedSigningAlgorithms.Contains(alg))
+ {
+ Logger.LogDebug("Failed to get valid alg header");
+ result.SetError("Invalid 'alg' value.");
+ return Task.CompletedTask;
+ }
+
+ if (!token.TryGetHeaderValue(JwtClaimTypes.JsonWebKey, out var jwkValues))
+ {
+ Logger.LogDebug("Failed to get jwk header");
+ result.SetError("Invalid 'jwk' value.");
+ return Task.CompletedTask;
+ }
+
+ var jwkJson = JsonSerializer.Serialize(jwkValues);
+
+ JsonWebKey jwk;
+ try
+ {
+ jwk = new JsonWebKey(jwkJson);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug("Error parsing DPoP jwk value: {error}", ex.Message);
+ result.SetError("Invalid 'jwk' value.");
+ return Task.CompletedTask;
+ }
+
+ if (jwk.HasPrivateKey)
+ {
+ Logger.LogDebug("'jwk' value contains a private key.");
+ result.SetError("'jwk' value contains a private key.");
+ return Task.CompletedTask;
+ }
+
+ result.JsonWebKey = jwkJson;
+ result.JsonWebKeyThumbprint = jwk.CreateThumbprint();
+
+ var cnf = context.AccessTokenClaims.FirstOrDefault(c => c.Type == JwtClaimTypes.Confirmation);
+ if (cnf is not { Value.Length: > 0 })
+ {
+ Logger.LogDebug("Empty cnf value in DPoP access token.");
+ result.SetError("Missing 'cnf' value.");
+ return Task.CompletedTask;
+ }
+ try
+ {
+ var cnfJson = JsonSerializer.Deserialize>(cnf.Value);
+ if (cnfJson == null)
+ {
+ Logger.LogDebug("Null cnf value in DPoP access token.");
+ result.SetError("Invalid 'cnf' value.");
+ return Task.CompletedTask;
+ }
+ else if (cnfJson.TryGetValue(JwtClaimTypes.ConfirmationMethods.JwkThumbprint, out var jktJson))
+ {
+ var accessTokenJkt = jktJson.ToString();
+ if (accessTokenJkt == result.JsonWebKeyThumbprint)
+ {
+ result.Confirmation = cnf.Value;
+ }
+ else
+ {
+ Logger.LogDebug("jkt in DPoP access token does not match proof token key thumbprint.");
+ }
+ }
+ else
+ {
+ Logger.LogDebug("jkt member missing from cnf claim in DPoP access token.");
+ }
+ }
+ catch (JsonException e)
+ {
+ Logger.LogDebug("Failed to parse DPoP cnf claim: {JsonExceptionMessage}", e.Message);
+ }
+ if (result.Confirmation == null)
+ {
+ result.SetError("Invalid 'cnf' value.");
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Validates the signature.
+ ///
+ protected virtual async Task ValidateSignature(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken cancellationToken = default)
+ {
+ TokenValidationResult? tokenValidationResult = null;
+
+ try
+ {
+ var key = new JsonWebKey(result.JsonWebKey);
+ var tvp = new TokenValidationParameters
+ {
+ ValidateAudience = false,
+ ValidateIssuer = false,
+ ValidateLifetime = false,
+ IssuerSigningKey = key,
+ };
+
+ var handler = new JsonWebTokenHandler();
+ tokenValidationResult = await handler.ValidateTokenAsync(context.ProofToken, tvp);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug("Error parsing DPoP token: {error}", ex.Message);
+ result.SetError("Invalid signature on DPoP token.");
+ }
+
+ if (tokenValidationResult?.Exception != null)
+ {
+ Logger.LogDebug("Error parsing DPoP token: {error}", tokenValidationResult.Exception.Message);
+ result.SetError("Invalid signature on DPoP token.");
+ }
+
+ if (tokenValidationResult != null)
+ {
+ result.Payload = tokenValidationResult.Claims;
+ }
+ }
+
+ ///
+ /// Validates the payload.
+ ///
+ protected virtual async Task ValidatePayload(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ {
+ if(result.Payload is null )
+ {
+ result.SetError("Missing payload");
+ return;
+ }
+
+ if (result.Payload.TryGetValue(JwtClaimTypes.DPoPAccessTokenHash, out var ath))
+ {
+ result.AccessTokenHash = ath as string;
+ }
+
+ if (string.IsNullOrEmpty(result.AccessTokenHash))
+ {
+ result.SetError("Invalid 'ath' value.");
+ return;
+ }
+
+ var bytes = Encoding.UTF8.GetBytes(context.AccessToken);
+ var hash = SHA256.HashData(bytes);
+
+ var accessTokenHash = Base64Url.Encode(hash);
+ if (accessTokenHash != result.AccessTokenHash)
+ {
+ result.SetError("Invalid 'ath' value.");
+ return;
+ }
+
+ if (result.Payload.TryGetValue(JwtClaimTypes.JwtId, out var jti))
+ {
+ if (jti is not string jtiString)
+ {
+ result.SetError("Invalid 'jti' value.");
+ return;
+ }
+ var jtiBytes = Encoding.UTF8.GetBytes(jtiString);
+ result.TokenIdHash = Base64Url.Encode(SHA256.HashData(jtiBytes));
+ }
+
+ if (string.IsNullOrEmpty(result.TokenIdHash))
+ {
+ result.SetError("Invalid 'jti' value.");
+ return;
+ }
+
+ if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpMethod, out var htm) || !context.Method.Equals(htm))
+ {
+ result.SetError("Invalid 'htm' value.");
+ return;
+ }
+
+ if (!result.Payload.TryGetValue(JwtClaimTypes.DPoPHttpUrl, out var htu) || !context.Url.Equals(htu))
+ {
+ result.SetError("Invalid 'htu' value.");
+ return;
+ }
+
+ if (result.Payload.TryGetValue(JwtClaimTypes.IssuedAt, out var iat))
+ {
+ result.IssuedAt = iat switch
+ {
+ int i => i,
+ long l => l,
+ _ => result.IssuedAt
+ };
+ }
+
+ if (!result.IssuedAt.HasValue)
+ {
+ result.SetError("Invalid 'iat' value.");
+ return;
+ }
+
+ if (result.Payload.TryGetValue(JwtClaimTypes.Nonce, out var nonce))
+ {
+ result.Nonce = nonce as string;
+ }
+
+ await ValidateFreshness(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ Logger.LogDebug("Failed to validate DPoP token freshness");
+ return;
+ }
+
+ // we do replay at the end, so we only add to the reply cache if everything else is ok
+ await ValidateReplay(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ Logger.LogDebug("Detected replay of DPoP token");
+ }
+ }
+
+ ///
+ /// Validates if the token has been replayed.
+ ///
+ protected virtual async Task ValidateReplay(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken cancellationToken = default)
+ {
+ var dPoPOptions = OptionsMonitor.Get(context.Scheme);
+
+ if (await ReplayCache.Exists(result.TokenIdHash!, cancellationToken))
+ {
+ result.SetError("Detected DPoP proof token replay.");
+ return;
+ }
+
+ // get the largest skew based on how client's freshness is validated
+ var validateIat = dPoPOptions.ValidationMode != ExpirationValidationMode.Nonce;
+ var validateNonce = dPoPOptions.ValidationMode != ExpirationValidationMode.IssuedAt;
+ var skew = TimeSpan.Zero;
+ if (validateIat && dPoPOptions.ClientClockSkew > skew)
+ {
+ skew = dPoPOptions.ClientClockSkew;
+ }
+ if (validateNonce && dPoPOptions.ServerClockSkew > skew)
+ {
+ skew = dPoPOptions.ServerClockSkew;
+ }
+
+ // we do x2 here because clock might be before or after, so we're making cache duration
+ // longer than the likelihood of proof token expiration, which is done before replay
+ skew *= 2;
+ var cacheDuration = dPoPOptions.ProofTokenValidityDuration + skew;
+ var expiration = TimeProvider.GetUtcNow().Add(cacheDuration);
+ await ReplayCache.Add(result.TokenIdHash!, expiration, cancellationToken);
+ }
+
+ ///
+ /// Validates freshness of proofs.
+ ///
+ protected virtual async Task ValidateFreshness(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken cancellationToken = default)
+ {
+ var dPoPOptions = OptionsMonitor.Get(context.Scheme);
+
+ var validateIat = dPoPOptions.ValidationMode != ExpirationValidationMode.Nonce;
+ if (validateIat)
+ {
+ await ValidateIat(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ return;
+ }
+ }
+
+ var validateNonce = dPoPOptions.ValidationMode != ExpirationValidationMode.IssuedAt;
+ if (validateNonce)
+ {
+ await ValidateNonce(context, result, cancellationToken);
+ if (result.IsError)
+ {
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Validates the freshness of the iat value.
+ ///
+ protected virtual Task ValidateIat(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken _ = default)
+ {
+ // iat is required by an earlier validation, so result.IssuedAt will not be null
+ if (IsExpired(context, result, result.IssuedAt!.Value, ExpirationValidationMode.IssuedAt))
+ {
+ result.SetError("Invalid 'iat' value.");
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Validates the freshness of the nonce value.
+ ///
+ protected virtual async Task ValidateNonce(
+ DPoPProofValidationContext context,
+ DPoPProofValidationResult result,
+ CancellationToken _ = default)
+ {
+ if (string.IsNullOrWhiteSpace(result.Nonce))
+ {
+ result.SetError("Missing 'nonce' value.", OidcConstants.TokenErrors.UseDPoPNonce);
+ result.ServerIssuedNonce = CreateNonce(context, result);
+ return;
+ }
+
+ var time = await GetUnixTimeFromNonceAsync(context, result);
+ if (time <= 0)
+ {
+ Logger.LogDebug("Invalid time value read from the 'nonce' value");
+
+ result.SetError("Invalid 'nonce' value.", OidcConstants.TokenErrors.UseDPoPNonce);
+ result.ServerIssuedNonce = CreateNonce(context, result);
+ return;
+ }
+
+ if (IsExpired(context, result, time, ExpirationValidationMode.Nonce))
+ {
+ Logger.LogDebug("DPoP 'nonce' expiration failed. It's possible that the server farm clocks might not be closely synchronized, so consider setting the ServerClockSkew on the DPoPOptions on the IdentityServerOptions.");
+
+ result.SetError("Invalid 'nonce' value.", OidcConstants.TokenErrors.UseDPoPNonce);
+ result.ServerIssuedNonce = CreateNonce(context, result);
+ return;
+ }
+ }
+
+ ///
+ /// Creates a nonce value to return to the client.
+ ///
+ protected virtual string CreateNonce(DPoPProofValidationContext context, DPoPProofValidationResult result)
+ {
+ var now = TimeProvider.GetUtcNow().ToUnixTimeSeconds();
+ return DataProtector.Protect(now.ToString());
+ }
+
+ ///
+ /// Reads the time the nonce was created.
+ ///
+ protected virtual ValueTask GetUnixTimeFromNonceAsync(DPoPProofValidationContext context, DPoPProofValidationResult result)
+ {
+ try
+ {
+ var value = DataProtector.Unprotect(result.Nonce!); // nonce is required by an earlier validation
+ if (long.TryParse(value, out long iat))
+ {
+ return ValueTask.FromResult(iat);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug("Error parsing DPoP 'nonce' value: {error}", ex.ToString());
+ }
+
+ return ValueTask.FromResult(0);
+ }
+
+ ///
+ /// Validates the expiration of the DPoP proof.
+ /// Returns true if the time is beyond the allowed limits, false otherwise.
+ ///
+ protected virtual bool IsExpired(DPoPProofValidationContext context, DPoPProofValidationResult result, long time,
+ ExpirationValidationMode mode)
+ {
+ var dpopOptions = OptionsMonitor.Get(context.Scheme);
+ var validityDuration = dpopOptions.ProofTokenValidityDuration;
+ var skew = mode == ExpirationValidationMode.Nonce ? dpopOptions.ServerClockSkew
+ : dpopOptions.ClientClockSkew;
+
+ return IsExpired(validityDuration, skew, time);
+ }
+
+ internal bool IsExpired(TimeSpan validityDuration, TimeSpan clockSkew, long time)
+ {
+ var now = TimeProvider.GetUtcNow().ToUnixTimeSeconds();
+ var start = now + (int) clockSkew.TotalSeconds;
+ if (start < time)
+ {
+ var diff = time - now;
+ Logger.LogDebug("Expiration check failed. Creation time was too far in the future. The time being checked was {iat}, and clock is now {now}. The time difference is {diff}", time, now, diff);
+ return true;
+ }
+
+ var expiration = time + (int) validityDuration.TotalSeconds;
+ var end = now - (int) clockSkew.TotalSeconds;
+ if (expiration < end)
+ {
+ var diff = now - expiration;
+ Logger.LogDebug("Expiration check failed. Expiration has already happened. The expiration was at {exp}, and clock is now {now}. The time difference is {diff}", expiration, now, diff);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultReplayCache.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultReplayCache.cs
new file mode 100644
index 0000000..709b8ea
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/DefaultReplayCache.cs
@@ -0,0 +1,41 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Default implementation of the replay cache using IDistributedCache
+///
+public class DefaultReplayCache : IReplayCache
+{
+ private const string Prefix = "DPoPJwtBearerEvents-DPoPReplay-jti-";
+
+ private readonly IDistributedCache _cache;
+
+ ///
+ /// Constructs new instances of .
+ ///
+ public DefaultReplayCache(IDistributedCache cache)
+ {
+ _cache = cache;
+ }
+
+ ///
+ public async Task Add(string handle, DateTimeOffset expiration, CancellationToken cancellationToken)
+ {
+ var options = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpiration = expiration
+ };
+
+ await _cache.SetAsync(Prefix + handle, [], options, cancellationToken);
+ }
+
+ ///
+ public async Task Exists(string handle, CancellationToken cancellationToken)
+ {
+ return await _cache.GetAsync(Prefix + handle, cancellationToken) != null;
+ }
+}
\ No newline at end of file
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/ExpirationValidationMode.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/ExpirationValidationMode.cs
new file mode 100644
index 0000000..bd4b3f1
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/ExpirationValidationMode.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Controls how the issued at time of proof tokens is validated.
+///
+public enum ExpirationValidationMode
+{
+ ///
+ /// Validate the time from the server-issued nonce.
+ ///
+ Nonce,
+ ///
+ /// Validate the time from the iat claim in the proof token.
+ ///
+ IssuedAt,
+ ///
+ /// Validate both the nonce and the iat claim.
+ ///
+ Both
+}
+
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/IDPoPProofValidator.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/IDPoPProofValidator.cs
new file mode 100644
index 0000000..46eef4f
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/IDPoPProofValidator.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Validates DPoP Proofs.
+///
+public interface IDPoPProofValidator
+{
+ ///
+ /// Validates the DPoP proof.
+ ///
+ Task Validate(DPoPProofValidationContext context, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs b/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs
new file mode 100644
index 0000000..96e1308
--- /dev/null
+++ b/src/AspNetCore.Authentication.JwtBearer/DPoP/IReplayCache.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+///
+/// Detects replay of proof tokens.
+///
+public interface IReplayCache
+{
+ ///
+ /// Adds a hashed jti to the cache.
+ ///
+ Task Add(string jtiHash, DateTimeOffset expiration, CancellationToken cancellationToken = default);
+
+
+ ///
+ /// Checks if a cached jti hash exists in the hash.
+ ///
+ Task Exists(string jtiHash, CancellationToken cancellationToken = default);
+}
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj b/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj
new file mode 100644
index 0000000..467a8de
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net8.0;net9.0
+ enable
+ enable
+ false
+ true
+ Duende.AspNetCore.Authentication.JwtBearer.Tests
+ Duende.AspNetCore.Authentication.JwtBearer
+ true
+
+ NU1507
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AccessTokenCnfTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AccessTokenCnfTests.cs
new file mode 100644
index 0000000..68f1978
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AccessTokenCnfTests.cs
@@ -0,0 +1,94 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text.Json;
+using IdentityModel;
+using Microsoft.IdentityModel.Tokens;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class AccessTokenCnfTests : DPoPProofValidatorTestBase
+{
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_cnf_should_fail()
+ {
+ Context.AccessTokenClaims
+ .ShouldNotContain(c => c.Type == JwtClaimTypes.Confirmation);
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Missing 'cnf' value.");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task empty_cnf_value_should_fail()
+ {
+ Context = Context with { AccessTokenClaims = [new Claim(JwtClaimTypes.Confirmation, string.Empty)] };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Missing 'cnf' value.");
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData("not-a-json-object")]
+ [InlineData("1")]
+ [InlineData("0")]
+ [InlineData("true")]
+ [InlineData("false")]
+ [InlineData("3.14159")]
+ [InlineData("[]")]
+ [InlineData("[123]")]
+ [InlineData("[\"asdf\"]")]
+ [InlineData("null")]
+ public async Task non_json_object_cnf_should_fail(string cnf)
+ {
+ Context = Context with { AccessTokenClaims = [new Claim(JwtClaimTypes.Confirmation, cnf)] };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'cnf' value.");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task cnf_missing_jkt_should_fail()
+ {
+ var cnfObject = new Dictionary
+ {
+ { "no-jkt-member-in-this-object", "causes-failure" }
+ };
+ Context = Context with { AccessTokenClaims = [new Claim(JwtClaimTypes.Confirmation, JsonSerializer.Serialize(cnfObject))] };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'cnf' value.");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task mismatched_jkt_should_fail()
+ {
+ // Generate a new key, and use that in the access token's cnf claim
+ // to simulate using the wrong key.
+ Context = Context with { AccessTokenClaims = [CnfClaim(GenerateJwk())] };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'cnf' value.");
+ }
+
+ private static string GenerateJwk()
+ {
+ var rsaKey = new RsaSecurityKey(RSA.Create(2048));
+ var jsonWebKey = JsonWebKeyConverter.ConvertFromRSASecurityKey(rsaKey);
+ jsonWebKey.Alg = "PS256";
+ return JsonSerializer.Serialize(jsonWebKey);
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs
new file mode 100644
index 0000000..825a1b3
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/AssertionExtensions.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+[ShouldlyMethods]
+public static class AssertionExtensions
+{
+ public static void ShouldBeInvalidProofWithDescription(this DPoPProofValidationResult result, string description)
+ {
+ result.IsError.ShouldBeTrue();
+ result.ErrorDescription.ShouldBe(description);
+ result.Error.ShouldBe(OidcConstants.TokenErrors.InvalidDPoPProof);
+ }
+
+ public static void ReplayCacheShouldNotBeCalled(this TestDPoPProofValidator validator)
+ {
+ validator.TestReplayCache.DidNotReceive().Add(Arg.Any(), Arg.Any());
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs
new file mode 100644
index 0000000..8d58e4d
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/DPoPProofValidatorTestBase.cs
@@ -0,0 +1,199 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using IdentityModel;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using NSubstitute;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public abstract class DPoPProofValidatorTestBase
+{
+ public DPoPProofValidatorTestBase()
+ {
+ ProofValidator = CreateProofValidator();
+ var jtiBytes = Encoding.UTF8.GetBytes(TokenId);
+ TokenIdHash = Base64Url.Encode(SHA256.HashData(jtiBytes));
+ }
+
+ // This is our system under test
+ protected TestDPoPProofValidator ProofValidator { get; init; }
+
+ protected DPoPOptions Options = new();
+ protected IReplayCache ReplayCache = Substitute.For();
+
+ public TestDPoPProofValidator CreateProofValidator()
+ {
+ var optionsMonitor = Substitute.For>();
+ optionsMonitor.Get(Arg.Any()).Returns(Options);
+
+ return new TestDPoPProofValidator(
+ optionsMonitor,
+ ReplayCache
+ );
+ }
+
+ protected DPoPProofValidationContext Context = new()
+ {
+ Scheme = "test-auth-scheme",
+ Method = HttpMethod,
+ AccessToken = AccessToken,
+ ProofToken = CreateDPoPProofToken(),
+ Url = HttpUrl
+ };
+
+ protected DPoPProofValidationResult Result = new();
+
+ // This is just an arbitrary date that we're going to do all our date arithmetic relative to.
+ // It was chosen because it is convenient to use - it is well within the range of DateTime
+ protected const long IssuedAt = 1704088800; // Mon Jan 01 2024 06:00:00 GMT+0000
+ protected const long ValidFor = 100;
+ protected const long ClockSkew = 10;
+ protected const string AccessToken = "test-access-token";
+ protected const string AccessTokenHash = "WXSA1LYsphIZPxnnP-TMOtF_C_nPwWp8v0tQZBMcSAU"; // Pre-computed sha256 hash of "test-access-token"
+
+ protected const string PrivateRsaJwk =
+ """
+ {
+ "D":"QeBWodq0hSYjfAxxo0VZleXLqwwZZeNWvvFfES4WyItao_-OJv1wKA7zfkZxbWkpK5iRbKrl2AMJ52AtUo5JJ6QZ7IjAQlgM0lBg3ltjb1aA0gBsK5XbiXcsV8DiAnRuy6-XgjAKPR8Lo-wZl_fdPbVoAmpSdmfn_6QXXPBai5i7FiyDbQa16pI6DL-5SCj7F78QDTRiJOqn5ElNvtoJEfJBm13giRdqeriFi3pCWo7H3QBgTEWtDNk509z4w4t64B2HTXnM0xj9zLnS42l7YplJC7MRibD4nVBMtzfwtGRKLj8beuDgtW9pDlQqf7RVWX5pHQgiHAZmUi85TEbYdQ",
+ "DP":"h2F54OMaC9qq1yqR2b55QNNaChyGtvmTHSdqZJ8lJFqvUorlz-Uocj2BTowWQnaMd8zRKMdKlSeUuSv4Z6WmjSxSsNbonI6_II5XlZLWYqFdmqDS-xCmJY32voT5Wn7OwB9xj1msDqrFPg-PqSBOh5OppjCqXqDFcNvSkQSajXc",
+ "DQ":"VABdS20Nxkmq6JWLQj7OjRxVJuYsHrfmWJmDA7_SYtlXaPUcg-GiHGQtzdDWEeEi0dlJjv9I3FdjKGC7CGwqtVygW38DzVYJsV2EmRNJc1-j-1dRs_pK9GWR4NYm0mVz_IhS8etIf9cfRJk90xU3AL3_J6p5WNF7I5ctkLpnt8M",
+ "E":"AQAB",
+ "Kty":"RSA",
+ "N":"yWWAOSV3Z_BW9rJEFvbZyeU-q2mJWC0l8WiHNqwVVf7qXYgm9hJC0j1aPHku_Wpl38DpK3Xu3LjWOFG9OrCqga5Pzce3DDJKI903GNqz5wphJFqweoBFKOjj1wegymvySsLoPqqDNVYTKp4nVnECZS4axZJoNt2l1S1bC8JryaNze2stjW60QT-mIAGq9konKKN3URQ12dr478m0Oh-4WWOiY4HrXoSOklFmzK-aQx1JV_SZ04eIGfSw1pZZyqTaB1BwBotiy-QA03IRxwIXQ7BSx5EaxC5uMCMbzmbvJqjt-q8Y1wyl-UQjRucgp7hkfHSE1QT3zEex2Q3NFux7SQ",
+ "P":"_T7MTkeOh5QyqlYCtLQ2RWf2dAJ9i3wrCx4nEDm1c1biijhtVTL7uJTLxwQIM9O2PvOi5Dq-UiGy6rhHZqf5akWTeHtaNyI-2XslQfaS3ctRgmGtRQL_VihK-R9AQtDx4eWL4h-bDJxPaxby_cVo_j2MX5AeoC1kNmcCdDf_X0M",
+ "Q":"y5ZSThaGLjaPj8Mk2nuD8TiC-sb4aAZVh9K-W4kwaWKfDNoPcNb_dephBNMnOp9M1br6rDbyG7P-Sy_LOOsKg3Q0wHqv4hnzGaOQFeMJH4HkXYdENC7B5JG9PefbC6zwcgZWiBnsxgKpScNWuzGF8x2CC-MdsQ1bkQeTPbJklIM",
+ "QI":"i716Vt9II_Rt6qnjsEhfE4bej52QFG9a1hSnx5PDNvRrNqR_RpTA0lO9qeXSZYGHTW_b6ZXdh_0EUwRDEDHmaxjkIcTADq6JLuDltOhZuhLUSc5NCKLAVCZlPcaSzv8-bZm57mVcIpx0KyFHxvk50___Jgx1qyzwLX03mPGUbDQ"
+ }
+ """;
+
+ protected const string PublicRsaJwk =
+ """
+ {
+ "kty":"RSA",
+ "use":"sig",
+ "e":"AQAB",
+ "n":"yWWAOSV3Z_BW9rJEFvbZyeU-q2mJWC0l8WiHNqwVVf7qXYgm9hJC0j1aPHku_Wpl38DpK3Xu3LjWOFG9OrCqga5Pzce3DDJKI903GNqz5wphJFqweoBFKOjj1wegymvySsLoPqqDNVYTKp4nVnECZS4axZJoNt2l1S1bC8JryaNze2stjW60QT-mIAGq9konKKN3URQ12dr478m0Oh-4WWOiY4HrXoSOklFmzK-aQx1JV_SZ04eIGfSw1pZZyqTaB1BwBotiy-QA03IRxwIXQ7BSx5EaxC5uMCMbzmbvJqjt-q8Y1wyl-UQjRucgp7hkfHSE1QT3zEex2Q3NFux7SQ"
+ }
+ """;
+ protected static readonly Dictionary PublicRsaJwkDeserialized = JsonSerializer.Deserialize>(PublicRsaJwk)!;
+
+ protected const string PrivateEcdsaJwk =
+ """
+ {
+ "alg": "ES256",
+ "crv": "P-256",
+ "d": "9CRuA1-1ATel3-CvNg7cT-l-WN8o6KPTvEMqMxhLhVI",
+ "ext": true,
+ "kid": "7exUU3NSbzLfBTLciHM_IJPKfa9sBCMaD-FdZ70jBGs",
+ "kty": "EC",
+ "x": "md6SP5IyW7kqjwqNS3fekeF-uXLz4iMwmm1tDjtZq1w",
+ "y": "uHzp1K3vnrqoVUwZ_7v3wxAr1reHPdkGoDGzH_pT0ak"
+ }
+ """;
+ protected const string PublicEcdsaJwk =
+ """
+ {
+ "alg": "ES256",
+ "crv": "P-256",
+ "ext": true,
+ "kid": "7exUU3NSbzLfBTLciHM_IJPKfa9sBCMaD-FdZ70jBGs",
+ "kty": "EC",
+ "x": "md6SP5IyW7kqjwqNS3fekeF-uXLz4iMwmm1tDjtZq1w",
+ "y": "uHzp1K3vnrqoVUwZ_7v3wxAr1reHPdkGoDGzH_pT0ak"
+ }
+ """;
+ protected static readonly Dictionary PublicEcdsaJwkDeserialized = JsonSerializer.Deserialize>(PublicEcdsaJwk)!;
+
+ protected static readonly byte[] PrivateHmacKey = CreateHmacKey();
+
+ private static byte[] CreateHmacKey()
+ {
+ byte[] randomBytes = new byte[64];
+ RandomNumberGenerator.Fill(randomBytes);
+ return randomBytes;
+ }
+
+ protected const string TokenId = "test-token-jti";
+ protected readonly string TokenIdHash;
+ protected const string HttpMethod = "GET";
+ protected const string HttpUrl = "https://example.com";
+
+ protected static string CreateDPoPProofToken(
+ string typ = "dpop+jwt",
+ string alg = SecurityAlgorithms.RsaSha256,
+ object? jwk = null,
+ string? jti = null,
+ string? htm = null,
+ string? htu = null,
+ string? ath = null)
+ {
+ var tokenHandler = new JsonWebTokenHandler();
+
+ var claims = new List();
+ if (jti != null)
+ {
+ claims.Add(new Claim(JwtClaimTypes.JwtId, Guid.NewGuid().ToString()));
+ }
+
+ if (htm != null)
+ {
+ claims.Add(new Claim(JwtClaimTypes.DPoPHttpMethod, htm));
+ }
+
+ if (htu != null)
+ {
+ claims.Add(new Claim(JwtClaimTypes.DPoPHttpUrl, htu));
+ }
+
+ if (ath != null)
+ {
+ claims.Add(new Claim(JwtClaimTypes.DPoPHttpUrl, ath));
+ }
+
+ var creds = alg switch
+ {
+ string s when s.StartsWith("ES") => new SigningCredentials(new JsonWebKey(PrivateEcdsaJwk), alg),
+ string s when s.StartsWith("RS") || s.StartsWith("PS") => new SigningCredentials(new JsonWebKey(PrivateRsaJwk), alg),
+ string s when s.StartsWith("HS") => new SigningCredentials(new SymmetricSecurityKey(PrivateHmacKey), alg),
+ "none" => null,
+ _ => throw new ArgumentException("alg value not mocked")
+ };
+
+ var jwkPayload = jwk ?? alg switch
+ {
+
+ string s when s.StartsWith("ES") => PublicEcdsaJwkDeserialized,
+ string s when s.StartsWith("RS")|| s.StartsWith("PS") => PublicRsaJwkDeserialized,
+ _ => "null"
+ };
+
+
+ var d = new SecurityTokenDescriptor
+ {
+ TokenType = typ,
+ IssuedAt = DateTime.UtcNow,
+ AdditionalHeaderClaims = new Dictionary
+ {
+ { JwtClaimTypes.JsonWebKey, jwkPayload },
+ },
+ Subject = new ClaimsIdentity(claims),
+ SigningCredentials = creds
+ };
+ return tokenHandler.CreateToken(d);
+ }
+
+ protected Claim CnfClaim(string jwkString)
+ {
+ jwkString ??= PublicRsaJwk;
+ var jwk = new JsonWebKey(jwkString);
+ var cnf = jwk.CreateThumbprintCnf();
+ return new Claim(JwtClaimTypes.Confirmation, cnf);
+ }
+}
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/FreshnessTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/FreshnessTests.cs
new file mode 100644
index 0000000..ef7fbc0
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/FreshnessTests.cs
@@ -0,0 +1,251 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+using Microsoft.AspNetCore.DataProtection;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class FreshnessTests : DPoPProofValidatorTestBase
+{
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task can_retrieve_issued_at_unix_time_from_nonce()
+ {
+ Result.Nonce = ProofValidator.TestDataProtector.Protect(IssuedAt.ToString());
+
+ var actual = await ProofValidator.GetUnixTimeFromNonceAsync(Context, Result);
+
+ actual.ShouldBe(IssuedAt);
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task invalid_nonce_is_treated_as_zero()
+ {
+ Result.Nonce = ProofValidator.TestDataProtector.Protect("garbage that isn't a long");
+
+ var actual = await ProofValidator.GetUnixTimeFromNonceAsync(Context, Result);
+
+ actual.ShouldBe(0);
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public void nonce_contains_data_protected_issued_at_unix_time()
+ {
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+
+ var actual = ProofValidator.CreateNonce(Context, new DPoPProofValidationResult());
+
+ ProofValidator.TestDataProtector.Unprotect(actual).ShouldBe(IssuedAt.ToString());
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData((string?) null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task missing_nonce_returns_use_dpop_nonce_with_server_issued_nonce(string? nonce)
+ {
+ Result.Nonce = nonce;
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+
+ await ProofValidator.ValidateNonce(Context, Result);
+
+ Result.IsError.ShouldBeTrue();
+ Result.Error.ShouldBe(OidcConstants.TokenErrors.UseDPoPNonce);
+ Result.ErrorDescription.ShouldBe("Missing 'nonce' value.");
+ Result.ServerIssuedNonce.ShouldNotBeNull();
+ ProofValidator.TestDataProtector.Unprotect(Result.ServerIssuedNonce).ShouldBe(IssuedAt.ToString());
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData("null")]
+ [InlineData("garbage")]
+ public async Task invalid_nonce_returns_use_dpop_nonce_with_server_issued_nonce(string? nonce)
+ {
+ Result.Nonce = nonce;
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+
+ await ProofValidator.ValidateNonce(Context, Result);
+
+ Result.IsError.ShouldBeTrue();
+ Result.Error.ShouldBe(OidcConstants.TokenErrors.UseDPoPNonce);
+ Result.ErrorDescription.ShouldBe("Invalid 'nonce' value.");
+ Result.ServerIssuedNonce.ShouldNotBeNull();
+ ProofValidator.TestDataProtector.Unprotect(Result.ServerIssuedNonce).ShouldBe(IssuedAt.ToString());
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task expired_nonce_returns_use_dpop_nonce_with_server_issued_nonce()
+ {
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ServerClockSkew = TimeSpan.FromSeconds(ClockSkew);
+
+ // We go past validity and clock skew nonce to cause expiration
+ var now = IssuedAt + ClockSkew + ValidFor + 1;
+
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(now));
+
+ Result.Nonce = ProofValidator.TestDataProtector.Protect(IssuedAt.ToString());
+
+ await ProofValidator.ValidateNonce(Context, Result);
+
+ Result.IsError.ShouldBeTrue();
+ Result.Error.ShouldBe(OidcConstants.TokenErrors.UseDPoPNonce);
+ Result.ErrorDescription.ShouldBe("Invalid 'nonce' value.");
+ Result.ServerIssuedNonce.ShouldNotBeNull();
+ ProofValidator.TestDataProtector.Unprotect(Result.ServerIssuedNonce).ShouldBe(now.ToString());
+ }
+
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ // Around the maximum
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor + ClockSkew + 1, true)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor + ClockSkew, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor + ClockSkew - 1, false)]
+
+ // Around the maximum, neglecting clock skew
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor - 1, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + ValidFor + 1, false)]
+
+ // Around the maximum, with clock skew disabled
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt + ValidFor - 1, false)]
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt + ValidFor, false)]
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt + ValidFor + 1, true)]
+
+ // Around the minimum
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt - ClockSkew - 1, true)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt - ClockSkew, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt - ClockSkew + 1, false)]
+
+ // Around the minimum, neglecting clock skew
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt - 1, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt, false)]
+ [InlineData(IssuedAt, ValidFor, ClockSkew, IssuedAt + 1, false)]
+
+ // Around the minimum, with clock skew disabled
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt - 1, true)]
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt, false)]
+ [InlineData(IssuedAt, ValidFor, 0, IssuedAt + 1, false)]
+ public void expiration_check_is_correct_at_boundaries(long issuedAt, long validFor, long clockSkew, long now, bool expected)
+ {
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(now));
+
+ var actual = ProofValidator.IsExpired(TimeSpan.FromSeconds(validFor), TimeSpan.FromSeconds(clockSkew), issuedAt);
+ actual.ShouldBe(expected);
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData(ClockSkew, 0, ExpirationValidationMode.IssuedAt)]
+ [InlineData(0, ClockSkew, ExpirationValidationMode.Nonce)]
+ public void use_client_or_server_clock_skew_depending_on_validation_mode(int clientClockSkew, int serverClockSkew,
+ ExpirationValidationMode mode)
+ {
+ Options.ClientClockSkew = TimeSpan.FromSeconds(clientClockSkew);
+ Options.ServerClockSkew = TimeSpan.FromSeconds(serverClockSkew);
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+
+ // We pick a time that needs some clock skew to be valid
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + 1));
+
+ // We're not expired because we're using the right clock skew
+ ProofValidator.IsExpired(Context, Result, IssuedAt, mode).ShouldBeFalse();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task unexpired_proofs_do_not_set_errors()
+ {
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ClientClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.IssuedAt = IssuedAt;
+
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+
+ await ProofValidator.ValidateIat(Context, Result);
+
+ Result.IsError.ShouldBeFalse();
+ Result.Error.ShouldBeNull();
+ Result.ErrorDescription.ShouldBeNull();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task expired_proofs_set_errors()
+ {
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ClientClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.IssuedAt = IssuedAt;
+
+ // Go forward into the future beyond the expiration and clock skew
+ var now = IssuedAt + ClockSkew + ValidFor + 1;
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(now));
+
+ await ProofValidator.ValidateIat(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'iat' value.");
+ }
+
+ [Theory]
+ [InlineData(ExpirationValidationMode.IssuedAt)]
+ [InlineData(ExpirationValidationMode.Both)]
+ [Trait("Category", "Unit")]
+ public async Task validate_iat_when_option_is_set(ExpirationValidationMode mode)
+ {
+ Options.ValidationMode = mode;
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ClientClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.IssuedAt = IssuedAt;
+ if (mode == ExpirationValidationMode.Both)
+ {
+ Options.ServerClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.Nonce = ProofValidator.TestDataProtector.Protect(IssuedAt.ToString());
+ }
+
+ // Adjust time to exactly on the expiration
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + ClockSkew));
+
+ await ProofValidator.ValidateFreshness(Context, Result);
+ Result.IsError.ShouldBeFalse();
+
+ // Now adjust time to one second later and try again
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + ClockSkew + 1));
+ await ProofValidator.ValidateFreshness(Context, Result);
+ Result.IsError.ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData(ExpirationValidationMode.Nonce)]
+ [InlineData(ExpirationValidationMode.Both)]
+ [Trait("Category", "Unit")]
+ public async Task validate_nonce_when_option_is_set(ExpirationValidationMode mode)
+ {
+ Options.ValidationMode = mode;
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ServerClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.Nonce = ProofValidator.TestDataProtector.Protect(IssuedAt.ToString());
+ if (mode == ExpirationValidationMode.Both)
+ {
+ Result.IssuedAt = IssuedAt;
+ }
+
+ // Adjust time to exactly on the expiration
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + ClockSkew));
+
+ await ProofValidator.ValidateFreshness(Context, Result);
+ Result.IsError.ShouldBeFalse();
+
+ // Now adjust time to one second later and try again
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + ClockSkew + 1));
+ await ProofValidator.ValidateFreshness(Context, Result);
+ Result.IsError.ShouldBeTrue();
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/HeaderTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/HeaderTests.cs
new file mode 100644
index 0000000..a6c94e9
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/HeaderTests.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.IdentityModel.Tokens;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class HeaderTests : DPoPProofValidatorTestBase
+{
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task malformed_proof_tokens_fail()
+ {
+ Context = Context with { ProofToken = "This is obviously not a jwt" };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Malformed DPoP token.");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task proof_tokens_with_incorrect_typ_header_fail()
+ {
+ Context = Context with { ProofToken = CreateDPoPProofToken(typ: "at+jwt") }; //Not dpop+jwt!
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'typ' value.");
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData(SecurityAlgorithms.RsaSha256)]
+ [InlineData(SecurityAlgorithms.RsaSha384)]
+ [InlineData(SecurityAlgorithms.RsaSha512)]
+ [InlineData(SecurityAlgorithms.RsaSsaPssSha256)]
+ [InlineData(SecurityAlgorithms.RsaSsaPssSha384)]
+ [InlineData(SecurityAlgorithms.RsaSsaPssSha512)]
+ [InlineData(SecurityAlgorithms.EcdsaSha256)]
+ [InlineData(SecurityAlgorithms.EcdsaSha384)]
+ [InlineData(SecurityAlgorithms.EcdsaSha512)]
+ public async Task valid_algorithms_succeed(string alg)
+ {
+ var useECAlgorithm = alg.StartsWith("ES");
+ Context = Context with
+ {
+ ProofToken = CreateDPoPProofToken(alg: alg),
+ AccessTokenClaims = [CnfClaim(useECAlgorithm ? PublicEcdsaJwk : PublicRsaJwk)]
+ };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.IsError.ShouldBeFalse(Result.ErrorDescription);
+ }
+
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData(SecurityAlgorithms.None)]
+ [InlineData(SecurityAlgorithms.HmacSha256)]
+ [InlineData(SecurityAlgorithms.HmacSha384)]
+ [InlineData(SecurityAlgorithms.HmacSha512)]
+ public async Task disallowed_algorithms_fail(string alg)
+ {
+ Context = Context with { ProofToken = CreateDPoPProofToken(alg: alg) };
+
+ await ProofValidator.ValidateHeader(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'alg' value.");
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs
new file mode 100644
index 0000000..97c31bf
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/PayloadTests.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class PayloadTests : DPoPProofValidatorTestBase
+{
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_payload_fails()
+ {
+ Result.Payload = null;
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Missing payload");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_ath_fails()
+ {
+ Result.Payload = new Dictionary();
+ Result.Payload.ShouldNotContainKey(JwtClaimTypes.DPoPAccessTokenHash);
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'ath' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task mismatched_ath_fails()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, "garbage that does not hash to the access token" }
+ };
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'ath' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_jti_fails()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ };
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'jti' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_htm_fails()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ };
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'htm' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_htu_fails()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ { JwtClaimTypes.DPoPHttpMethod, HttpMethod },
+ };
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'htu' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task missing_iat_fails()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ { JwtClaimTypes.DPoPHttpMethod, HttpMethod },
+ { JwtClaimTypes.DPoPHttpUrl, HttpUrl }
+ };
+
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'iat' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task expired_payload_fails()
+ {
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+ Options.ClientClockSkew = TimeSpan.FromSeconds(ClockSkew);
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ { JwtClaimTypes.DPoPHttpMethod, HttpMethod },
+ { JwtClaimTypes.DPoPHttpUrl, HttpUrl },
+ { JwtClaimTypes.IssuedAt, IssuedAt },
+ };
+
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt + ValidFor + ClockSkew + 1));
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Invalid 'iat' value.");
+ ProofValidator.ReplayCacheShouldNotBeCalled();
+ }
+
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task valid_payload_succeeds()
+ {
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ { JwtClaimTypes.DPoPHttpMethod, HttpMethod },
+ { JwtClaimTypes.DPoPHttpUrl, HttpUrl },
+ { JwtClaimTypes.IssuedAt, IssuedAt }
+ };
+
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.IsError.ShouldBeFalse(Result.ErrorDescription);
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs
new file mode 100644
index 0000000..81084df
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/ReplayTests.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using IdentityModel;
+using NSubstitute;
+using Shouldly;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class ReplayTests : DPoPProofValidatorTestBase
+{
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task replays_detected_in_ValidatePayload_fail()
+ {
+ ProofValidator.TestReplayCache.Exists(TokenIdHash).Returns(true);
+ Result.Payload = new Dictionary
+ {
+ { JwtClaimTypes.DPoPAccessTokenHash, AccessTokenHash },
+ { JwtClaimTypes.JwtId, TokenId },
+ { JwtClaimTypes.DPoPHttpMethod, HttpMethod },
+ { JwtClaimTypes.DPoPHttpUrl, HttpUrl },
+ { JwtClaimTypes.IssuedAt, IssuedAt },
+ };
+ ProofValidator.TestTimeProvider.SetUtcNow(DateTimeOffset.FromUnixTimeSeconds(IssuedAt));
+ await ProofValidator.ValidatePayload(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Detected DPoP proof token replay.");
+ }
+
+ [Fact]
+ [Trait("Category", "Unit")]
+ public async Task replays_detected_in_ValidateReplay_fail()
+ {
+ ReplayCache.Exists(TokenIdHash).Returns(true);
+ Result.TokenIdHash = TokenIdHash;
+
+ await ProofValidator.ValidateReplay(Context, Result);
+
+ Result.ShouldBeInvalidProofWithDescription("Detected DPoP proof token replay.");
+ }
+
+ [Theory]
+ [Trait("Category", "Unit")]
+ [InlineData(true, false, ClockSkew, 0)]
+ [InlineData(false, true, 0, ClockSkew)]
+ [InlineData(true, true, ClockSkew, ClockSkew * 2)]
+ [InlineData(true, true, ClockSkew * 2, ClockSkew)]
+ [InlineData(true, true, ClockSkew * 2, ClockSkew * 2)]
+ public async Task new_proof_tokens_are_added_to_replay_cache(bool validateIat, bool validateNonce, int clientClockSkew, int serverClockSkew)
+ {
+ ReplayCache.Exists(TokenIdHash).Returns(false);
+
+ Options.ValidationMode = (validateIat && validateNonce) ? ExpirationValidationMode.Both
+ : validateIat ? ExpirationValidationMode.IssuedAt : ExpirationValidationMode.Nonce;
+ Options.ClientClockSkew = TimeSpan.FromSeconds(clientClockSkew);
+ Options.ServerClockSkew = TimeSpan.FromSeconds(serverClockSkew);
+ Options.ProofTokenValidityDuration = TimeSpan.FromSeconds(ValidFor);
+
+ Result.TokenIdHash = TokenIdHash;
+
+ await ProofValidator.ValidateReplay(Context, Result);
+
+ Result.IsError.ShouldBeFalse();
+ var skew = validateIat && validateNonce
+ ? Math.Max(clientClockSkew, serverClockSkew)
+ : (validateIat ? clientClockSkew : serverClockSkew);
+ var expectedExpiration = ProofValidator.TestTimeProvider.GetUtcNow()
+ .Add(TimeSpan.FromSeconds(skew * 2))
+ .Add(TimeSpan.FromSeconds(ValidFor));
+ await ReplayCache.Received().Add(TokenIdHash, expectedExpiration);
+ }
+}
\ No newline at end of file
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/TestDPoPProofValidator.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/TestDPoPProofValidator.cs
new file mode 100644
index 0000000..a55d9c0
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoP/TestDPoPProofValidator.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Time.Testing;
+using NSubstitute;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+
+public class TestDPoPProofValidator : DefaultDPoPProofValidator
+{
+ public TestDPoPProofValidator(
+ IOptionsMonitor optionsMonitor,
+ IReplayCache replayCache) : base(
+ optionsMonitor,
+ new EphemeralDataProtectionProvider(),
+ replayCache,
+ new FakeTimeProvider(),
+ Substitute.For>())
+ { }
+
+ public IDataProtector TestDataProtector => DataProtector;
+ public FakeTimeProvider TestTimeProvider => (FakeTimeProvider) TimeProvider;
+ public IReplayCache TestReplayCache => ReplayCache;
+
+ public new Task ValidateHeader(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default) => base.ValidateHeader(context, result, cancellationToken);
+
+ public new Task ValidatePayload(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ => base.ValidatePayload(context, result, cancellationToken);
+
+ public new Task ValidateReplay(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ => base.ValidateReplay(context, result, cancellationToken);
+
+ public new Task ValidateFreshness(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ => base.ValidateFreshness(context, result, cancellationToken);
+
+ public new Task ValidateIat(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ => base.ValidateIat(context, result, cancellationToken);
+
+ public new Task ValidateNonce(DPoPProofValidationContext context, DPoPProofValidationResult result, CancellationToken cancellationToken = default)
+ => base.ValidateNonce(context, result, cancellationToken);
+
+ public new string CreateNonce(DPoPProofValidationContext context, DPoPProofValidationResult result)
+ => base.CreateNonce(context, result);
+
+ public new ValueTask GetUnixTimeFromNonceAsync(DPoPProofValidationContext context, DPoPProofValidationResult result)
+ => base.GetUnixTimeFromNonceAsync(context, result);
+
+ public new virtual bool IsExpired(TimeSpan validityDuration, TimeSpan clockSkew, long issuedAtTime)
+ => base.IsExpired(validityDuration, clockSkew, issuedAtTime);
+
+ public new virtual bool IsExpired(DPoPProofValidationContext context, DPoPProofValidationResult result, long time,
+ ExpirationValidationMode mode) =>
+ base.IsExpired(context, result, time, mode);
+}
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs
new file mode 100644
index 0000000..274c804
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/DPoPIntegrationTests.cs
@@ -0,0 +1,181 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Duende.AccessTokenManagement;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using Duende.AspNetCore.Authentication.JwtBearer.DPoP;
+using Duende.AspNetCore.TestFramework;
+using Duende.IdentityServer.Models;
+using IdentityModel;
+using IdentityModel.Client;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.IdentityModel.Tokens;
+using Shouldly;
+using Xunit.Abstractions;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer;
+
+public class DPoPIntegrationTests(ITestOutputHelper testOutputHelper)
+{
+ Client DPoPOnlyClient = new()
+ {
+ ClientId = "client1",
+ ClientSecrets = [new Secret("secret".ToSha256())],
+ RequireDPoP = true,
+ AllowedScopes = ["openid", "profile", "scope1"],
+ AllowedGrantTypes = GrantTypes.Code,
+ RedirectUris = ["https://app/signin-oidc"],
+ PostLogoutRedirectUris = ["https://app/signout-callback-oidc"]
+ };
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task missing_token_fails()
+ {
+ var api = await CreateDPoPApi();
+
+ var result = await api.HttpClient.GetAsync("/");
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
+ }
+
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task incorrect_token_type_fails()
+ {
+ var api = await CreateDPoPApi();
+ var bearerToken = "unimportant opaque value";
+ api.HttpClient.SetBearerToken(bearerToken);
+
+ var result = await api.HttpClient.GetAsync("/");
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task valid_token_and_proof_succeeds()
+ {
+ var identityServer = await CreateIdentityServer();
+ identityServer.Clients.Add(DPoPOnlyClient);
+ var jwk = CreateJwk();
+ var api = await CreateDPoPApi();
+
+ var app = new AppHost(identityServer, api, "client1", testOutputHelper,
+ configureUserTokenManagementOptions: opt => opt.DPoPJsonWebKey = jwk);
+ await app.Initialize();
+
+ // Login and get token for api call
+ await app.LoginAsync("sub");
+ var response = await app.BrowserClient.GetAsync(app.Url("/user_token"));
+ var token = await response.Content.ReadFromJsonAsync();
+ token.ShouldNotBeNull();
+ token.AccessToken.ShouldNotBeNull();
+ token.DPoPJsonWebKey.ShouldNotBeNull();
+ api.HttpClient.SetToken(OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP, token.AccessToken);
+
+ // Create proof token for api call
+ var dpopService =
+ new DefaultDPoPProofService(new TestDPoPNonceStore(), new NullLogger());
+ var proof = await dpopService.CreateProofTokenAsync(new DPoPProofRequest
+ {
+ AccessToken = token.AccessToken,
+ DPoPJsonWebKey = jwk,
+ Method = "GET",
+ Url = "http://localhost/"
+ });
+ proof.ShouldNotBeNull();
+ api.HttpClient.DefaultRequestHeaders.Add(OidcConstants.HttpHeaders.DPoP, proof.ProofToken);
+
+ var result = await api.HttpClient.GetAsync("/");
+
+ result.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task excessively_large_proof_fails()
+ {
+ var identityServer = await CreateIdentityServer(idsrv =>
+ {
+ idsrv.Clients.Add(DPoPOnlyClient);
+ });
+
+ var jwk = CreateJwk();
+ var maxLength = 50;
+ var api = await CreateDPoPApi(opt => opt.ProofTokenMaxLength = maxLength);
+
+ var app = new AppHost(identityServer, api, "client1", testOutputHelper,
+ configureUserTokenManagementOptions: opt => opt.DPoPJsonWebKey = jwk);
+ await app.Initialize();
+
+ // Login and get token for api call
+ await app.LoginAsync("sub");
+ var response = await app.BrowserClient.GetAsync(app.Url("/user_token"));
+ var token = await response.Content.ReadFromJsonAsync();
+ token.ShouldNotBeNull();
+ token.AccessToken.ShouldNotBeNull();
+ token.DPoPJsonWebKey.ShouldNotBeNull();
+ api.HttpClient.SetToken(OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP, token.AccessToken);
+
+ // Create proof token for api call
+ var dpopService =
+ new DefaultDPoPProofService(new TestDPoPNonceStore(), new NullLogger());
+ var proof = await dpopService.CreateProofTokenAsync(new DPoPProofRequest
+ {
+ AccessToken = token.AccessToken,
+ DPoPJsonWebKey = jwk,
+ Method = "GET",
+ Url = "http://localhost/",
+ DPoPNonce = new string('x', maxLength + 1) // <--- Most important part of the test
+ });
+ proof.ShouldNotBeNull();
+ api.HttpClient.DefaultRequestHeaders.Add(OidcConstants.HttpHeaders.DPoP, proof.ProofToken);
+
+ var result = await api.HttpClient.GetAsync("/");
+
+ result.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
+ }
+
+ public async Task CreateIdentityServer(Action? setup = null)
+ {
+ var host = new IdentityServerHost(testOutputHelper);
+ setup?.Invoke(host);
+ await host.Initialize();
+ return host;
+ }
+
+ private async Task CreateDPoPApi(Action? configureDPoP = null)
+ {
+ var baseAddress = "https://api";
+ var identityServer = await CreateIdentityServer();
+ var api = new ApiHost(identityServer, testOutputHelper, baseAddress);
+ api.OnConfigureServices += services =>
+ services.ConfigureDPoPTokensForScheme(ApiHost.AuthenticationScheme,
+ opt =>
+ {
+ opt.TokenMode = DPoPMode.DPoPOnly;
+ configureDPoP?.Invoke(opt);
+ });
+ api.OnConfigure += app =>
+ app.MapGet("/", () => "default route")
+ .RequireAuthorization();
+ await api.Initialize();
+ return api;
+ }
+
+ private static string CreateJwk()
+ {
+ var rsaKey = new RsaSecurityKey(RSA.Create(2048));
+ var jwkKey = JsonWebKeyConverter.ConvertFromSecurityKey(rsaKey);
+ jwkKey.Alg = "RS256";
+ var jwk = JsonSerializer.Serialize(jwkKey);
+ return jwk;
+ }
+}
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/GlobalSuppressions.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/GlobalSuppressions.cs
new file mode 100644
index 0000000..5222b41
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/GlobalSuppressions.cs
@@ -0,0 +1,6 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Diagnostics.CodeAnalysis;
+
+[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "tests use a different naming convention")]
diff --git a/test/AspNetCore.Authentication.JwtBearer.Tests/TestDPoPNonceStore.cs b/test/AspNetCore.Authentication.JwtBearer.Tests/TestDPoPNonceStore.cs
new file mode 100644
index 0000000..35c60a0
--- /dev/null
+++ b/test/AspNetCore.Authentication.JwtBearer.Tests/TestDPoPNonceStore.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.AccessTokenManagement;
+
+namespace Duende.AspNetCore.Authentication.JwtBearer;
+
+public class TestDPoPNonceStore : IDPoPNonceStore
+{
+ private string _nonce = string.Empty;
+ public Task GetNonceAsync(DPoPNonceContext context, CancellationToken cancellationToken = new())
+ {
+ return Task.FromResult(_nonce);
+ }
+
+ public Task StoreNonceAsync(DPoPNonceContext context, string nonce, CancellationToken cancellationToken = new())
+ {
+ _nonce = nonce;
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/ApiHost.cs b/test/TestFramework/ApiHost.cs
new file mode 100644
index 0000000..e86febb
--- /dev/null
+++ b/test/TestFramework/ApiHost.cs
@@ -0,0 +1,106 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.IdentityServer.Models;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Abstractions;
+
+namespace Duende.AspNetCore.TestFramework;
+
+public class ApiHost : GenericHost
+{
+ public const string AuthenticationScheme = "token";
+
+ public int? ApiStatusCodeToReturn { get; set; }
+
+ private readonly IdentityServerHost _identityServerHost;
+ public event Action ApiInvoked = ctx => { };
+
+ public ApiHost(IdentityServerHost identityServerHost, ITestOutputHelper testOutputHelper, string baseAddress = "https://api")
+ : base(testOutputHelper, baseAddress)
+ {
+ _identityServerHost = identityServerHost;
+
+ OnConfigureServices += ConfigureServices;
+ OnConfigure += Configure;
+ }
+
+ private void ConfigureServices(IServiceCollection services)
+ {
+ services.AddRouting();
+ services.AddAuthorization();
+
+ services.AddAuthentication(AuthenticationScheme)
+ .AddJwtBearer(AuthenticationScheme, options =>
+ {
+ options.Authority = _identityServerHost.Url();
+ options.Audience = _identityServerHost.Url("/resources");
+ options.MapInboundClaims = false;
+ options.BackchannelHttpHandler = _identityServerHost.Server.CreateHandler();
+ });
+ }
+
+ private void Configure(IApplicationBuilder app)
+ {
+ app.Use(async(context, next) =>
+ {
+ ApiInvoked.Invoke(context);
+ if (ApiStatusCodeToReturn != null)
+ {
+ context.Response.StatusCode = ApiStatusCodeToReturn.Value;
+ ApiStatusCodeToReturn = null;
+ return;
+ }
+
+ await next();
+ });
+
+ app.UseRouting();
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ // app.UseEndpoints(endpoints =>
+ // {
+ // // endpoints.Map("/{**catch-all}", async context =>
+ // // {
+ // // // capture body if present
+ // // var body = default(string);
+ // // if (context.Request.HasJsonContentType())
+ // // {
+ // // using (var sr = new StreamReader(context.Request.Body))
+ // // {
+ // // body = await sr.ReadToEndAsync();
+ // // }
+ // // }
+ // //
+ // // // capture request headers
+ // // var requestHeaders = new Dictionary>();
+ // // foreach (var header in context.Request.Headers)
+ // // {
+ // // var values = new List(header.Value.Select(v => v));
+ // // requestHeaders.Add(header.Key, values);
+ // // }
+ // //
+ // // var response = new ApiResponse(
+ // // context.Request.Method,
+ // // context.Request.Path.Value,
+ // // context.User.FindFirst(("sub"))?.Value,
+ // // context.User.FindFirst(("client_id"))?.Value,
+ // // context.User.Claims.Select(x => new ClaimRecord(x.Type, x.Value)).ToArray())
+ // // {
+ // // Body = body,
+ // // RequestHeaders = requestHeaders
+ // // };
+ // //
+ // // context.Response.StatusCode = ApiStatusCodeToReturn ?? 200;
+ // // ApiStatusCodeToReturn = null;
+ // //
+ // // context.Response.ContentType = "application/json";
+ // // await context.Response.WriteAsync(JsonSerializer.Serialize(response));
+ // // });
+ // });
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/AppHost.cs b/test/TestFramework/AppHost.cs
new file mode 100644
index 0000000..bc9b552
--- /dev/null
+++ b/test/TestFramework/AppHost.cs
@@ -0,0 +1,202 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Web;
+using Duende.AccessTokenManagement.OpenIdConnect;
+using IdentityModel;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using RichardSzalay.MockHttp;
+using Shouldly;
+using Xunit.Abstractions;
+
+namespace Duende.AspNetCore.TestFramework;
+
+public class AppHost : GenericHost
+{
+ private readonly IdentityServerHost _identityServerHost;
+ private readonly ApiHost _apiHost;
+ private readonly string _clientId;
+ private readonly Action? _configureUserTokenManagementOptions;
+
+ public AppHost(
+ IdentityServerHost identityServerHost,
+ ApiHost apiHost,
+ string clientId,
+ ITestOutputHelper testOutputHelper,
+ string baseAddress = "https://app",
+ Action? configureUserTokenManagementOptions = default)
+ : base(testOutputHelper, baseAddress)
+ {
+ _identityServerHost = identityServerHost;
+ _apiHost = apiHost;
+ _clientId = clientId;
+ _configureUserTokenManagementOptions = configureUserTokenManagementOptions;
+ OnConfigureServices += ConfigureServices;
+ OnConfigure += Configure;
+ }
+
+ public MockHttpMessageHandler? IdentityServerHttpHandler { get; set; }
+
+ private void ConfigureServices(IServiceCollection services)
+ {
+ services.AddRouting();
+ services.AddAuthorization();
+
+ services.AddAuthentication(options =>
+ {
+ options.DefaultScheme = "cookie";
+ options.DefaultChallengeScheme = "oidc";
+ options.DefaultSignOutScheme = "oidc";
+ })
+ .AddCookie("cookie")
+ .AddOpenIdConnect("oidc", options =>
+ {
+ options.Authority = _identityServerHost.Url();
+
+ options.ClientId = _clientId;
+ options.ClientSecret = "secret";
+ options.ResponseType = "code";
+ options.ResponseMode = "query";
+
+ options.DisableTelemetry = true;
+ options.MapInboundClaims = false;
+ options.GetClaimsFromUserInfoEndpoint = false;
+ options.SaveTokens = true;
+
+ options.Scope.Clear();
+ var client = _identityServerHost.Clients.Single(x => x.ClientId == _clientId);
+ foreach (var scope in client.AllowedScopes)
+ {
+ options.Scope.Add(scope);
+ }
+
+ if (client.AllowOfflineAccess)
+ {
+ options.Scope.Add("offline_access");
+ }
+
+ var identityServerHandler = _identityServerHost.Server.CreateHandler();
+ if (IdentityServerHttpHandler != null)
+ {
+ // allow discovery document
+ IdentityServerHttpHandler.When("/.well-known/*")
+ .Respond(identityServerHandler);
+
+ options.BackchannelHttpHandler = IdentityServerHttpHandler;
+ }
+ else
+ {
+ options.BackchannelHttpHandler = identityServerHandler;
+ }
+
+ options.ProtocolValidator.RequireNonce = false;
+ });
+
+ services.AddDistributedMemoryCache();
+ services.AddOpenIdConnectAccessTokenManagement(opt =>
+ {
+ _configureUserTokenManagementOptions?.Invoke(opt);
+ });
+
+ }
+
+ private void Configure(IApplicationBuilder app)
+ {
+ app.UseAuthentication();
+ app.UseRouting();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet("/login", async context =>
+ {
+ await context.ChallengeAsync(new AuthenticationProperties
+ {
+ RedirectUri = "/"
+ });
+ });
+
+ endpoints.MapGet("/logout", async context =>
+ {
+ await context.SignOutAsync();
+ });
+
+ endpoints.MapGet("/user_token", async context =>
+ {
+ var token = await context.GetUserAccessTokenAsync();
+ await context.Response.WriteAsJsonAsync(token);
+ });
+
+ endpoints.MapGet("/user_token_with_resource/{resource}", async (string resource, HttpContext context) =>
+ {
+ var token = await context.GetUserAccessTokenAsync(new UserTokenRequestParameters
+ {
+ Resource = resource
+ });
+ await context.Response.WriteAsJsonAsync(token);
+ });
+
+ endpoints.MapGet("/client_token", async context =>
+ {
+ var token = await context.GetClientAccessTokenAsync();
+ await context.Response.WriteAsJsonAsync(token);
+ });
+ });
+ }
+
+ public async Task LoginAsync(string sub, string? sid = null, bool verifyDpopThumbprintSent = false)
+ {
+ await _identityServerHost.CreateIdentityServerSessionCookieAsync(sub, sid);
+ return await OidcLoginAsync(verifyDpopThumbprintSent);
+ }
+
+ public async Task OidcLoginAsync(bool verifyDpopThumbprintSent)
+ {
+ var response = await BrowserClient.GetAsync(Url("/login"));
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // authorize
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(_identityServerHost.Url("/connect/authorize"));
+
+ if (verifyDpopThumbprintSent)
+ {
+ var queryParams = HttpUtility.ParseQueryString(response.Headers.Location.Query);
+ queryParams.AllKeys.ShouldContain(OidcConstants.AuthorizeRequest.DPoPKeyThumbprint);
+ }
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // client callback
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(Url("/signin-oidc"));
+
+ response = await BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // root
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldBe("/");
+
+ response = await BrowserClient.GetAsync(Url(response.Headers.Location.ToString()));
+ return response;
+ }
+
+ public async Task LogoutAsync(string? sid = null)
+ {
+ var response = await BrowserClient.GetAsync(Url("/logout") + "?sid=" + sid);
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // endsession
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(_identityServerHost.Url("/connect/endsession"));
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // logout
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(_identityServerHost.Url("/account/logout"));
+
+ response = await _identityServerHost.BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // post logout redirect uri
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldStartWith(Url("/signout-callback-oidc"));
+
+ response = await BrowserClient.GetAsync(response.Headers.Location.ToString());
+ response.StatusCode.ShouldBe((HttpStatusCode)302); // root
+ response.Headers.Location!.ToString().ToLowerInvariant().ShouldBe("/");
+
+ response = await BrowserClient.GetAsync(Url(response.Headers.Location.ToString()));
+ return response;
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/GenericHost.cs b/test/TestFramework/GenericHost.cs
new file mode 100644
index 0000000..44a3e78
--- /dev/null
+++ b/test/TestFramework/GenericHost.cs
@@ -0,0 +1,196 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Net;
+using System.Reflection;
+using System.Security.Claims;
+using Meziantou.Extensions.Logging.Xunit;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit.Abstractions;
+
+namespace Duende.AspNetCore.TestFramework;
+
+public class GenericHost
+{
+ public GenericHost(ITestOutputHelper testOutputHelper, string baseAddress = "https://server")
+ {
+ if (baseAddress.EndsWith("/")) baseAddress = baseAddress.Substring(0, baseAddress.Length - 1);
+ _baseAddress = baseAddress;
+ _testOutputHelper = testOutputHelper;
+ }
+
+ protected readonly string _baseAddress;
+ IServiceProvider _appServices = default!;
+
+ public Assembly? HostAssembly { get; set; }
+ public bool IsDevelopment { get; set; }
+ private TestServer? _server;
+ public TestServer Server
+ {
+ get
+ {
+ return _server ?? throw new InvalidOperationException(
+ $"Attempt to use {nameof(Server)} before it was initialized. Did you forget to call ${Initialize}?");
+ }
+ private set => _server = value;
+ }
+
+ private TestBrowserClient? _browserClient;
+ public TestBrowserClient BrowserClient
+ {
+ get =>
+ _browserClient ?? throw new InvalidOperationException(
+ $"Attempt to use {nameof(BrowserClient)} before is was initialized. Did you forget to call {nameof(Initialize)}");
+ private set => _browserClient = value;
+ }
+
+ private HttpClient? _httpClient;
+ public HttpClient HttpClient
+ {
+ get =>
+ _httpClient ?? throw new InvalidOperationException(
+ $"Attempt to use ${nameof(HttpClient)} before is was initialized. Did you forget to call {nameof(Initialize)}");
+ private set => _httpClient = value;
+ }
+
+ private readonly ITestOutputHelper _testOutputHelper;
+
+ public T Resolve()
+ where T : notnull
+ {
+ // not calling dispose on scope on purpose
+ return _appServices.GetRequiredService().CreateScope().ServiceProvider
+ .GetRequiredService();
+ }
+
+ public string Url(string? path = null)
+ {
+ path = path ?? string.Empty;
+ if (!path.StartsWith("/")) path = "/" + path;
+ return _baseAddress + path;
+ }
+
+ public async Task Initialize()
+ {
+ var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = IsDevelopment ? "Development" : "Production",
+ ApplicationName = HostAssembly?.GetName()?.Name
+ });
+ builder.WebHost
+ .UseTestServer();
+
+ ConfigureServices(builder.Services);
+ var webApplication = builder.Build();
+ Configure(webApplication);
+
+ await webApplication.StartAsync();
+
+ Server = webApplication.GetTestServer();
+ BrowserClient = new TestBrowserClient(Server.CreateHandler());
+ HttpClient = Server.CreateClient();
+ }
+
+ public event Action OnConfigureServices = services => { };
+ public event Action OnConfigure = app => { };
+
+ void ConfigureServices(IServiceCollection services)
+ {
+ // This adds log messages to the output of our tests when they fail.
+ // See https://www.meziantou.net/how-to-view-logs-from-ilogger-in-xunitdotnet.htm
+ services.AddLogging(options =>
+ {
+ // If you need different log output to understand a test failure, configure it here
+ options.SetMinimumLevel(LogLevel.Error);
+ options.AddFilter("Duende", LogLevel.Information);
+ options.AddFilter("Duende.IdentityServer.License", LogLevel.Error);
+ options.AddFilter("Duende.IdentityServer.Startup", LogLevel.Error);
+
+ options.AddProvider(new XUnitLoggerProvider(_testOutputHelper, new XUnitLoggerOptions
+ {
+ IncludeCategory = true,
+ }));
+ });
+
+ OnConfigureServices(services);
+ _appServices = services.BuildServiceProvider();
+ }
+
+ void Configure(WebApplication builder)
+ {
+ OnConfigure(builder);
+
+ ConfigureSignin(builder);
+ ConfigureSignout(builder);
+ }
+
+ void ConfigureSignout(WebApplication app)
+ {
+ app.Use(async (ctx, next) =>
+ {
+ if (ctx.Request.Path == "/__signout")
+ {
+ await ctx.SignOutAsync();
+ ctx.Response.StatusCode = 204;
+ return;
+ }
+
+ await next();
+ });
+ }
+
+ public async Task RevokeSessionCookieAsync()
+ {
+ var response = await BrowserClient.GetAsync(Url("__signout"));
+ response.StatusCode.ShouldBe((HttpStatusCode)204);
+ }
+
+ void ConfigureSignin(WebApplication app)
+ {
+ app.Use(async (ctx, next) =>
+ {
+ if (ctx.Request.Path == "/__signin")
+ {
+ if (_userToSignIn is not object)
+ {
+ throw new Exception("No User Configured for SignIn");
+ }
+
+ var props = _propsToSignIn ?? new AuthenticationProperties();
+ await ctx.SignInAsync(_userToSignIn, props);
+
+ _userToSignIn = null;
+ _propsToSignIn = null;
+
+ ctx.Response.StatusCode = 204;
+ return;
+ }
+
+ await next();
+ });
+ }
+
+ ClaimsPrincipal? _userToSignIn;
+ AuthenticationProperties? _propsToSignIn;
+
+ public async Task IssueSessionCookieAsync(params Claim[] claims)
+ {
+ _userToSignIn = new ClaimsPrincipal(new ClaimsIdentity(claims, "test", "name", "role"));
+ var response = await BrowserClient.GetAsync(Url("__signin"));
+ response.StatusCode.ShouldBe((HttpStatusCode)204);
+ }
+ public Task IssueSessionCookieAsync(AuthenticationProperties props, params Claim[] claims)
+ {
+ _propsToSignIn = props;
+ return IssueSessionCookieAsync(claims);
+ }
+ public Task IssueSessionCookieAsync(string sub, params Claim[] claims)
+ {
+ return IssueSessionCookieAsync(claims.Append(new Claim("sub", sub)).ToArray());
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/IdentityServerHost.cs b/test/TestFramework/IdentityServerHost.cs
new file mode 100644
index 0000000..4945551
--- /dev/null
+++ b/test/TestFramework/IdentityServerHost.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Security.Claims;
+using Duende.IdentityServer.Models;
+using Duende.IdentityServer.Services;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Xunit.Abstractions;
+
+namespace Duende.AspNetCore.TestFramework;
+
+public class IdentityServerHost : GenericHost
+{
+ public IdentityServerHost(ITestOutputHelper testOutputHelper, string baseAddress = "https://identityserver")
+ : base(testOutputHelper, baseAddress)
+ {
+ OnConfigureServices += ConfigureServices;
+ OnConfigure += Configure;
+ }
+
+ public List Clients { get; } = new();
+ public List IdentityResources { get; } =
+ [
+ new IdentityResources.OpenId(),
+ new IdentityResources.Profile(),
+ new IdentityResources.Email()
+ ];
+ public List ApiScopes { get; } =
+ [
+ new ApiScope("scope1")
+ ];
+ public List ApiResources { get; } =
+ [
+ new ApiResource("urn:api1"),
+ new ApiResource("urn:api2")
+ ];
+
+ private void ConfigureServices(IServiceCollection services)
+ {
+ services.AddRouting();
+ services.AddAuthorization();
+
+ services.AddIdentityServer(options=>
+ {
+ options.EmitStaticAudienceClaim = true;
+
+ // Artificially low durations to force retries
+ options.DPoP.ServerClockSkew = TimeSpan.Zero;
+ options.DPoP.ProofTokenValidityDuration = TimeSpan.FromSeconds(1);
+ })
+ .AddInMemoryClients(Clients)
+ .AddInMemoryIdentityResources(IdentityResources)
+ .AddInMemoryApiResources(ApiResources)
+ .AddInMemoryApiScopes(ApiScopes);
+ }
+
+ private void Configure(IApplicationBuilder app)
+ {
+ app.UseRouting();
+
+ app.UseIdentityServer();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapGet("/account/login", context =>
+ {
+ return Task.CompletedTask;
+ });
+
+ endpoints.MapGet("/account/logout", async context =>
+ {
+ // signout as if the user were prompted
+ await context.SignOutAsync();
+
+ var logoutId = context.Request.Query["logoutId"];
+ var interaction = context.RequestServices.GetRequiredService();
+
+ var signOutContext = await interaction.GetLogoutContextAsync(logoutId);
+
+ context.Response.Redirect(signOutContext.PostLogoutRedirectUri ?? "/");
+ });
+ });
+ }
+
+ public async Task CreateIdentityServerSessionCookieAsync(string sub, string? sid = null)
+ {
+ var props = new AuthenticationProperties();
+
+ if (!string.IsNullOrWhiteSpace(sid))
+ {
+ props.Items.Add("session_id", sid);
+ }
+
+ await IssueSessionCookieAsync(props, new Claim("sub", sub));
+ }
+
+ public string CreateIdToken(string sub, string clientId)
+ {
+ var descriptor = new SecurityTokenDescriptor
+ {
+ Issuer = _baseAddress,
+ Audience = clientId,
+ Claims = new Dictionary
+ {
+ { "sub", sub }
+ }
+ };
+
+ var handler = new JsonWebTokenHandler();
+ return handler.CreateToken(descriptor);
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/TestBrowserClient.cs b/test/TestFramework/TestBrowserClient.cs
new file mode 100644
index 0000000..1e6e51f
--- /dev/null
+++ b/test/TestFramework/TestBrowserClient.cs
@@ -0,0 +1,265 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Diagnostics;
+using System.Net;
+using Shouldly;
+
+namespace Duende.AspNetCore.TestFramework;
+
+public class TestBrowserClient : HttpClient
+{
+ class CookieHandler(HttpMessageHandler next) : DelegatingHandler(next)
+ {
+ public CookieContainer CookieContainer { get; } = new();
+ public Uri CurrentUri { get; private set; } = default!;
+ public HttpResponseMessage LastResponse { get; private set; } = default!;
+
+ protected override async Task SendAsync(HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ CurrentUri = request.RequestUri!;
+ string cookieHeader = CookieContainer.GetCookieHeader(request.RequestUri!);
+ if (!string.IsNullOrEmpty(cookieHeader))
+ {
+ request.Headers.Add("Cookie", cookieHeader);
+ }
+
+ var response = await base.SendAsync(request, cancellationToken);
+
+ if (response.Headers.Contains("Set-Cookie"))
+ {
+ var responseCookieHeader = string.Join(",", response.Headers.GetValues("Set-Cookie"));
+ CookieContainer.SetCookies(request.RequestUri!, responseCookieHeader);
+ }
+
+ LastResponse = response;
+
+ return response;
+ }
+ }
+
+ private CookieHandler _handler;
+
+ public CookieContainer CookieContainer => _handler.CookieContainer;
+ public Uri CurrentUri => _handler.CurrentUri;
+ public HttpResponseMessage LastResponse => _handler.LastResponse;
+
+ public TestBrowserClient(HttpMessageHandler handler)
+ : this(new CookieHandler(handler))
+ {
+ }
+
+ private TestBrowserClient(CookieHandler handler)
+ : base(handler)
+ {
+ _handler = handler;
+ }
+
+ public Cookie? GetCookie(string name)
+ {
+ return GetCookie(_handler.CurrentUri.ToString(), name);
+ }
+
+ public Cookie? GetCookie(string uri, string name)
+ {
+ return _handler.CookieContainer.GetCookies(new Uri(uri)).Where(x => x.Name == name).FirstOrDefault();
+ }
+
+ public void RemoveCookie(string name)
+ {
+ RemoveCookie(CurrentUri.ToString(), name);
+ }
+
+ public void RemoveCookie(string uri, string name)
+ {
+ var cookie = CookieContainer.GetCookies(new Uri(uri)).FirstOrDefault(x => x.Name == name);
+ if (cookie != null)
+ {
+ cookie.Expired = true;
+ }
+ }
+
+ public async Task FollowRedirectAsync()
+ {
+ LastResponse.StatusCode.ShouldBe((HttpStatusCode)302);
+ var location = LastResponse.Headers.Location!.ToString();
+ await GetAsync(location);
+ }
+
+ // TODO - Finish conversion from CSQuery to AngleSharp (CSQuery is unmaintained)
+ // public Task PostFormAsync(HtmlForm form)
+ // {
+ // return PostAsync(form.Action, new FormUrlEncodedContent(form.Inputs));
+ // }
+ //
+ // public Task ReadFormAsync(string? selector = null)
+ // {
+ // return ReadFormAsync(LastResponse, selector);
+ // }
+ //
+ // public async Task ReadFormAsync(HttpResponseMessage response, string? selector = null)
+ // {
+ // response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ //
+ // var htmlForm = new HtmlForm
+ // {
+ //
+ // };
+ //
+ // var html = await response.Content.ReadAsStringAsync();
+ //
+ // var context = BrowsingContext.New(Configuration.Default);
+ // var dom = await context.OpenAsync(req => req.Content(html));
+ //
+ // var form = dom.QuerySelector(selector ?? "form");
+ //
+ // form.ShouldNotBeNull();
+ //
+ //
+ // var postUrl = form.Action;
+ // if (!postUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ // {
+ // if (postUrl.StartsWith("/"))
+ // {
+ // postUrl = CurrentUri.Scheme + "://" + CurrentUri.Authority + postUrl;
+ // }
+ // else
+ // {
+ // postUrl = CurrentUri + postUrl;
+ // }
+ // }
+ // htmlForm.Action = postUrl;
+ //
+ //
+ // var data = new Dictionary();
+ //
+ // foreach (var input in form.Elements)
+ // {
+ // var name = input.GetAttribute("name");
+ // var value = input.GetAttribute("value");
+ //
+ // if (!data.ContainsKey(name))
+ // {
+ // data.Add(name, value);
+ // }
+ // else
+ // {
+ // data[name] = value;
+ // }
+ // }
+ // htmlForm.Inputs = data;
+ //
+ // return htmlForm;
+ // }
+ //
+ //
+ // public Task ReadElementTextAsync(string selector)
+ // {
+ // return ReadElementTextAsync(LastResponse, selector);
+ // }
+ // public async Task ReadElementTextAsync(HttpResponseMessage response, string selector)
+ // {
+ // var html = await response.Content.ReadAsStringAsync();
+ //
+ // var dom = new CQ(html);
+ // var element = dom.Find(selector);
+ // return element.Text();
+ // }
+ //
+ // public Task ReadElementAttributeAsync(string selector, string attribute)
+ // {
+ // return ReadElementAttributeAsync(LastResponse, selector, attribute);
+ // }
+ // public async Task ReadElementAttributeAsync(HttpResponseMessage response, string selector, string attribute)
+ // {
+ // var html = await response.Content.ReadAsStringAsync();
+ //
+ // var dom = new CQ(html);
+ // var element = dom.Find(selector);
+ // return element.Attr(attribute);
+ // }
+ //
+ // public Task AssertExistsAsync(string selector)
+ // {
+ // return AssertExistsAsync(LastResponse, selector);
+ // }
+ //
+ // public async Task AssertExistsAsync(HttpResponseMessage response, string selector)
+ // {
+ // response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ //
+ // var html = await response.Content.ReadAsStringAsync();
+ //
+ // var dom = new CQ(html);
+ // var element = dom.Find(selector);
+ // element.Length.ShouldBeGreaterThan(0);
+ // }
+ //
+ // public Task AssertNotExistsAsync(string selector)
+ // {
+ // return AssertNotExistsAsync(selector);
+ // }
+ // public async Task AssertNotExistsAsync(HttpResponseMessage response, string selector)
+ // {
+ // response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ //
+ // var html = await response.Content.ReadAsStringAsync();
+ //
+ // var dom = new CQ(html);
+ // var element = dom.Find(selector);
+ // element.Length.ShouldBe(0);
+ // }
+ //
+ // public Task AssertErrorPageAsync(string? error = null)
+ // {
+ // return AssertErrorPageAsync(LastResponse, error);
+ // }
+ // public async Task AssertErrorPageAsync(HttpResponseMessage response, string? error = null)
+ // {
+ // response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ // await AssertExistsAsync(response, ".error-page");
+ //
+ // if (!string.IsNullOrWhiteSpace(error))
+ // {
+ // var errorText = await ReadElementTextAsync(response, ".alert.alert-danger");
+ // errorText.ShouldContain(error);
+ // }
+ // }
+ //
+ // public Task AssertValidationErrorAsync(string? error = null)
+ // {
+ // return AssertValidationErrorAsync(error);
+ // }
+ // public async Task AssertValidationErrorAsync(HttpResponseMessage response, string? error = null)
+ // {
+ // response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ // await AssertExistsAsync(response, ".validation-summary-errors");
+ //
+ // if (!string.IsNullOrWhiteSpace(error))
+ // {
+ // var errorText = await ReadElementTextAsync(response, ".validation-summary-errors");
+ // errorText.ToLowerInvariant().ShouldContain(error.ToLowerInvariant());
+ // }
+ // }
+}
+
+[DebuggerDisplay("{Action}, Inputs: {Inputs.Count}")]
+public class HtmlForm(string? action = null)
+{
+ public string? Action { get; set; } = action;
+ public Dictionary Inputs { get; set; } = new Dictionary();
+
+ public string? this[string key]
+ {
+ get
+ {
+ if (Inputs.TryGetValue(key, out var item)) return item;
+ return null;
+ }
+ set
+ {
+ Inputs[key] = value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/TestFramework/TestFramework.csproj b/test/TestFramework/TestFramework.csproj
new file mode 100644
index 0000000..fee201a
--- /dev/null
+++ b/test/TestFramework/TestFramework.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0; net9.0
+ enable
+ enable
+ Duende.AspNetCore.TestFramework
+ true
+
+ NU1507
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file