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