diff --git a/.github/workflows/conformance-report-ci.yml b/.github/workflows/conformance-report-ci.yml new file mode 100644 index 000000000..135b14cee --- /dev/null +++ b/.github/workflows/conformance-report-ci.yml @@ -0,0 +1,200 @@ +# This was generated by tool. Edits will be overwritten. + +name: conformance-report/ci +on: + workflow_dispatch: + push: + paths: + - .config/dotnet-tools.json + - .github/workflows/conformance-report-** + - conformance-report/** + - identity-server/src/IdentityServer.ConformanceReport/** + - .editorconfig + - Directory.Packages.props + - global.json + - src.props + - test.props + pull_request: + paths: + - .config/dotnet-tools.json + - .github/workflows/conformance-report-** + - conformance-report/** + - identity-server/src/IdentityServer.ConformanceReport/** + - .editorconfig + - Directory.Packages.props + - global.json + - src.props + - test.props +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true +jobs: + verify-formatting: + name: Verify formatting + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] + permissions: + contents: read + defaults: + run: + shell: bash + working-directory: conformance-report + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: List .net sdks + run: dotnet --list-sdks + - name: Setup Dotnet + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 + with: + dotnet-version: 10.0.100 + - name: Restore + run: dotnet restore conformance-report.slnf + - name: Verify Formatting + run: dotnet format conformance-report.slnf --verify-no-changes --no-restore + build: + name: Build and test (unit) + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] + permissions: + actions: read + checks: write + contents: read + packages: write + defaults: + run: + shell: bash + working-directory: conformance-report + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: List .net sdks + run: dotnet --list-sdks + - name: Setup Dotnet + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 + with: + dotnet-version: 10.0.100 + - name: Restore + run: dotnet restore conformance-report.slnf + - name: Build + run: dotnet build conformance-report.slnf --no-restore -c Release + - name: Dotnet devcerts + run: dotnet dev-certs https --trust + - name: Test - test/ConformanceReport.Tests + run: >- + dotnet run --project test/ConformanceReport.Tests -c Release --no-build -- \ + --report-xunit-trx --report-xunit-trx-filename ConformanceReport.Tests.trx \ + --coverage --coverage-output-format cobertura \ + --coverage-output ConformanceReport.Tests.cobertura.xml + - id: test-report-test-ConformanceReport-Tests + name: Test report - test/ConformanceReport.Tests + if: github.event_name == 'push' && (success() || failure()) + uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 + with: + name: Test Report - test/ConformanceReport.Tests + path: '**/ConformanceReport.Tests.trx' + reporter: dotnet-trx + fail-on-error: true + fail-on-empty: true + - name: Publish test report link + run: echo "[Test Results - test/ConformanceReport.Tests](${{ steps.test-report-test-ConformanceReport-Tests.outputs.url_html }})" >> $GITHUB_STEP_SUMMARY + playwright: + name: Playwright tests + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] + permissions: + actions: read + checks: write + contents: read + defaults: + run: + shell: bash + working-directory: conformance-report + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + codeql: + name: CodeQL analyze + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] + defaults: + run: + shell: bash + working-directory: conformance-report + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + pack: + name: Pack, sign and push + if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') + needs: + - verify-formatting + - build + - playwright + - codeql + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] + permissions: + actions: read + contents: read + packages: write + defaults: + run: + shell: bash + working-directory: conformance-report + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: List .net sdks + run: dotnet --list-sdks + - name: Setup Dotnet + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 + with: + dotnet-version: 10.0.100 + - name: Tool restore + run: dotnet tool restore + - name: Pack conformance-report.slnf + run: dotnet pack -c Release conformance-report.slnf -o artifacts + - name: Sign packages + if: github.event == 'push' + run: >- + for file in artifacts/*.nupkg; do + dotnet NuGetKeyVaultSignTool sign "$file" --file-digest sha256 --timestamp-rfc3161 http://timestamp.digicert.com --azure-key-vault-url https://duendecodesigninghsm.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 NuGetPackageSigning + done + - name: Push packages to GitHub + if: github.ref == 'refs/heads/main' + 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 + if: github.ref == 'refs/heads/main' + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 + with: + name: artifacts + path: conformance-report/artifacts/*.nupkg + overwrite: true + retention-days: 15 diff --git a/.github/workflows/conformance-report-release.yml b/.github/workflows/conformance-report-release.yml new file mode 100644 index 000000000..30496b022 --- /dev/null +++ b/.github/workflows/conformance-report-release.yml @@ -0,0 +1,114 @@ +# This was generated by tool. Edits will be overwritten. + +name: conformance-report/release +on: + workflow_dispatch: + inputs: + version: + description: 'Version in format X.Y.Z, X.Y.Z-preview.N, or X.Y.Z-rc.N' + type: string + required: true + default: '0.0.0' + branch: + description: '(Optional) the name of the branch to release from' + type: string + required: false + default: 'main' + remove-tag-if-exists: + description: 'If set, will remove the existing tag. Use this if you have issues with the previous release action' + type: boolean + required: false + default: false +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true +jobs: + tag: + name: Tag and Pack + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + defaults: + run: + shell: bash + working-directory: conformance-report + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate Version Input + run: echo '${{ github.event.inputs.version }}' | grep -P '^\d+\.\d+\.\d+(-preview\.\d+|-rc\.\d+)?$' || (echo 'Invalid version format' && exit 1) + - name: Checkout target branch + if: github.event.inputs.branch != 'main' + run: git checkout ${{ github.event.inputs.branch }} + - name: Git Config + run: >- + git config --global user.email "github-bot@duendesoftware.com" + + git config --global user.name "Duende Software GitHub Bot" + - name: Git Config + if: github.event.inputs['remove-tag-if-exists'] == 'true' + run: >- + if git rev-parse cr-${{ github.event.inputs.version }} >/dev/null 2>&1; then + git tag -d cr-${{ github.event.inputs.version }} + git push --delete origin cr-${{ github.event.inputs.version }} + else + echo 'Tag cr-${{ github.event.inputs.version }} does not exist.' + fi + - name: Git Config + run: >- + git tag -a cr-${{ github.event.inputs.version }} -m "Release v${{ github.event.inputs.version }}" + + git push origin cr-${{ github.event.inputs.version }} + - name: List .net sdks + run: dotnet --list-sdks + - name: Setup Dotnet + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 + with: + dotnet-version: 10.0.100 + - name: Pack conformance-report.slnf + run: dotnet pack -c Release conformance-report.slnf -o artifacts + - name: Tool restore + run: dotnet tool restore + - name: Sign packages + run: >- + for file in artifacts/*.nupkg; do + dotnet NuGetKeyVaultSignTool sign "$file" --file-digest sha256 --timestamp-rfc3161 http://timestamp.digicert.com --azure-key-vault-url https://duendecodesigninghsm.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 NuGetPackageSigning + done + - name: Push packages to GitHub + 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@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 + with: + name: artifacts + path: conformance-report/artifacts/*.nupkg + overwrite: true + retention-days: 15 + publish: + name: Publish to nuget.org + needs: + - tag + runs-on: ubuntu-latest + environment: + name: nuget.org + steps: + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 + with: + name: artifacts + path: artifacts + - name: List .net sdks + run: dotnet --list-sdks + - name: Setup Dotnet + uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 + with: + dotnet-version: 10.0.100 + - name: List files + run: tree + shell: bash + - name: Push packages 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 diff --git a/Directory.Packages.props b/Directory.Packages.props index ae585ae6e..28e7fb534 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + @@ -82,6 +83,7 @@ + diff --git a/conformance-report/README.md b/conformance-report/README.md new file mode 100644 index 000000000..5d1c083f1 --- /dev/null +++ b/conformance-report/README.md @@ -0,0 +1,105 @@ +# Duende Conformance Report + +_Standalone conformance assessment for OAuth 2.1 and FAPI 2.0 Security Profile compliance._ + +## Overview + +Duende Conformance Report evaluates your IdentityServer configuration against OAuth 2.1 and FAPI 2.0 Security Profile requirements and generates an HTML report showing server and client configuration conformance. + +For installation and setup instructions, see the [Duende.IdentityServer.ConformanceReport](https://www.nuget.org/packages/Duende.IdentityServer.ConformanceReport) package. + +## Conformance Profiles + +### OAuth 2.1 + +OAuth 2.1 consolidates best practices from OAuth 2.0, including mandatory PKCE, removal of deprecated grant types, and enhanced security requirements. + +**Specification**: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-14 + +#### Server Rules + +| Rule | Name | Requirement | +|------|------|-------------| +| S01 | PKCE Support | Server must support PKCE | +| S02 | Password Grant Prohibition | Assessed at client level | +| S03 | PAR Availability | PAR endpoint should be enabled | +| S04 | Sender-Constrained Token Support | mTLS or DPoP should be available | +| S05 | Secure Signing Algorithms | No symmetric or insecure algorithms | +| S06 | JWT Clock Skew | Clock skew within 5 minutes recommended | +| S07 | DPoP Nonce Support | Server should support DPoP nonce validation | +| S08 | HTTP 303 Redirects | Use HTTP 303 to prevent POST resubmission | + +#### Client Rules + +| Rule | Name | Requirement | +|------|------|-------------| +| C01 | Grant Types | Only authorization_code, client_credentials, refresh_token allowed | +| C02 | PKCE Required | PKCE must be required | +| C03 | No Plain Text PKCE | Plain text PKCE must be disabled | +| C04 | Explicit Redirect URIs | No wildcard redirect URIs | +| C05 | Client Authentication | Confidential clients must require authentication | +| C06 | PAR Required | PAR recommended for enhanced security | +| C07 | Sender-Constrained Tokens | DPoP or mTLS recommended | +| C08 | Auth Code Lifetime | Authorization code lifetime ≤60 seconds | +| C09 | Refresh Token Rotation | Refresh tokens should use one-time-only usage | +| C10 | DPoP Nonce | DPoP nonce validation required if DPoP enabled | +| C11 | Secure Client Authentication | Use private_key_jwt or mTLS | +| C12 | Refresh Token Support | Authorization code clients should support refresh tokens | + +### FAPI 2.0 Security Profile + +FAPI 2.0 Security Profile defines security requirements for high-risk scenarios such as financial services, requiring stronger authentication, authorization, and token security. + +**Specification**: https://openid.net/specs/fapi-security-profile-2_0-final.html + +#### Server Rules + +| Rule | Name | Requirement | +|------|------|-------------| +| FS01 | PAR Required | PAR must be enabled and required | +| FS02 | Sender-Constrained Requirement | mTLS must be enabled | +| FS03 | Signing Algorithms | Only PS256/PS384/PS512 or ES256/ES384/ES512 allowed | +| FS04 | PAR Lifetime | PAR lifetime ≤600 seconds | +| FS05 | Token Mechanisms | mTLS or DPoP must be available | +| FS06 | Issuer Identification | Issuer identification response parameter required | +| FS07 | HTTP 303 Redirects | HTTP 303 redirects required | +| FS08 | PKCE Support | Server must support PKCE | + +#### Client Rules + +| Rule | Name | Requirement | +|------|------|-------------| +| FC01 | Grant Types | Only authorization_code and client_credentials allowed | +| FC02 | Confidential Client | Authorization code clients must be confidential | +| FC03 | PKCE S256 | PKCE required with S256 challenge method only | +| FC04 | PAR Required | PAR must be required | +| FC05 | Sender-Constrained Tokens | DPoP or mTLS required | +| FC06 | Secure Client Auth | private_key_jwt or mTLS required | +| FC07 | Auth Code Lifetime | Authorization code lifetime ≤60 seconds | +| FC08 | Refresh Token Rotation | Refresh token rotation required if refresh tokens enabled | +| FC09 | DPoP Nonce | DPoP nonce validation required if DPoP used | +| FC10 | Explicit Redirect URIs | No wildcard redirect URIs | +| FC11 | No Browser Tokens | Access tokens via browser must be disabled | +| FC12 | Request Object | Request object or PAR required | + +## Development + +### Prerequisites + +- .NET 10 SDK + +### Building + +```bash +dotnet build conformance-report/src/ConformanceReport/ConformanceReport.csproj +``` + +### Running Tests + +```bash +dotnet test conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj +``` + +## License + +This product requires a valid Duende Software license. For license terms, see the `LICENSE` file in the root of this repository or visit https://duendesoftware.com for more information. diff --git a/conformance-report/conformance-report.slnf b/conformance-report/conformance-report.slnf new file mode 100644 index 000000000..01616f319 --- /dev/null +++ b/conformance-report/conformance-report.slnf @@ -0,0 +1,10 @@ +{ + "solution": { + "path": "..\\products.slnx", + "projects": [ + "conformance-report\\src\\ConformanceReport\\ConformanceReport.csproj", + "conformance-report\\test\\ConformanceReport.Tests\\ConformanceReport.Tests.csproj", + "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj" + ] + } +} \ No newline at end of file diff --git a/conformance-report/src/ConformanceReport/Configuration/ConfigureConformanceReportAuthorizationPolicy.cs b/conformance-report/src/ConformanceReport/Configuration/ConfigureConformanceReportAuthorizationPolicy.cs new file mode 100644 index 000000000..545a90582 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Configuration/ConfigureConformanceReportAuthorizationPolicy.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Configuration; + +/// +/// Configures the authorization policy for the conformance report endpoint. +/// +internal class ConfigureConformanceReportAuthorizationPolicy + : IConfigureOptions +{ + private readonly ConformanceReportOptions _conformanceOptions; + + public ConfigureConformanceReportAuthorizationPolicy( + IOptions conformanceOptions) + => _conformanceOptions = conformanceOptions.Value; + + public void Configure(AuthorizationOptions options) + { + // Only register the policy if ConfigureAuthorization is provided + if (_conformanceOptions.ConfigureAuthorization != null) + { + options.AddPolicy( + _conformanceOptions.AuthorizationPolicyName, + _conformanceOptions.ConfigureAuthorization); + } + } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReport.csproj b/conformance-report/src/ConformanceReport/ConformanceReport.csproj new file mode 100644 index 000000000..98988d2ff --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReport.csproj @@ -0,0 +1,23 @@ + + + + Duende.ConformanceReport + Duende.ConformanceReport + Conformance assessment for FAPI 2.0 and OAuth 2.1 profiles + true + false + Library + $(NoWarn);NETSDK1086 + + + + + + + + + + + + + diff --git a/conformance-report/src/ConformanceReport/ConformanceReportClient.cs b/conformance-report/src/ConformanceReport/ConformanceReportClient.cs new file mode 100644 index 000000000..fbd846d32 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportClient.cs @@ -0,0 +1,42 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +/// +/// Represents a client for conformance assessment. +/// +internal sealed record ConformanceReportClient +{ + public required string ClientId { get; init; } + + public string? ClientName { get; init; } + + public required IReadOnlyCollection AllowedGrantTypes { get; init; } + + public required bool RequirePkce { get; init; } + + public required bool AllowPlainTextPkce { get; init; } + + public required IReadOnlyCollection RedirectUris { get; init; } + + public required bool RequireClientSecret { get; init; } + + public required IReadOnlyCollection ClientSecretTypes { get; init; } + + public required bool RequirePushedAuthorization { get; init; } + + public required bool RequireDPoP { get; init; } + + public required ConformanceReportDPoPValidationMode DPoPValidationMode { get; init; } + + public required int AuthorizationCodeLifetime { get; init; } + + public required bool AllowOfflineAccess { get; init; } + + public required ConformanceReportTokenUsage RefreshTokenUsage { get; init; } + + public required bool AllowAccessTokensViaBrowser { get; init; } + + public required bool RequireRequestObject { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportConstants.cs b/conformance-report/src/ConformanceReport/ConformanceReportConstants.cs new file mode 100644 index 000000000..f536059db --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportConstants.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +/// +/// Constants for the conformance assessment feature. +/// +internal static class ConformanceReportConstants +{ + /// + /// The conformance feature path segment. + /// + public const string FeaturePath = "conformance-report"; + + /// + /// The API version for conformance endpoints. + /// + public const string ApiVersion = "v1"; + + /// + /// The unique identifier for the conformance report (for GRC tool integration). + /// + public const string ReportId = "conformance-assessment"; + + /// + /// The display name for the conformance report (for GRC tool integration). + /// + public const string ReportName = "Conformance Assessment"; +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportDPoPValidationMode.cs b/conformance-report/src/ConformanceReport/ConformanceReportDPoPValidationMode.cs new file mode 100644 index 000000000..86defd8c4 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportDPoPValidationMode.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +[Flags] +internal enum ConformanceReportDPoPValidationMode +{ + None = 0, + Nonce = 1, + Iat = 2 +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportGrantTypes.cs b/conformance-report/src/ConformanceReport/ConformanceReportGrantTypes.cs new file mode 100644 index 000000000..5ead63d35 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportGrantTypes.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +internal static class ConformanceReportGrantTypes +{ + public const string AuthorizationCode = "authorization_code"; + public const string ClientCredentials = "client_credentials"; + public const string RefreshToken = "refresh_token"; + public const string Implicit = "implicit"; + public const string Password = "password"; + public const string DeviceCode = "urn:ietf:params:oauth:grant-type:device_code"; +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportLicenseInfo.cs b/conformance-report/src/ConformanceReport/ConformanceReportLicenseInfo.cs new file mode 100644 index 000000000..eb5ef8159 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportLicenseInfo.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +/// +/// Represents license information for the conformance report. +/// +public sealed record ConformanceReportLicenseInfo +{ + /// + /// The company name from the license. + /// + public string? CompanyName { get; init; } + + /// + /// The contact information from the license. + /// + public string? ContactInfo { get; init; } + + /// + /// The license serial number. + /// + public int? SerialNumber { get; init; } + + /// + /// The license expiration date. + /// + public DateTime? Expiration { get; init; } + + /// + /// The license edition name. + /// + public string? Edition { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportOptions.cs b/conformance-report/src/ConformanceReport/ConformanceReportOptions.cs new file mode 100644 index 000000000..c94abee12 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportOptions.cs @@ -0,0 +1,95 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authorization; + +namespace Duende.ConformanceReport; + +/// +/// Options for the conformance assessment feature. +/// +public class ConformanceReportOptions +{ + /// + /// Enable conformance endpoints. Requires valid license. + /// Default: false + /// + public bool Enabled { get; set; } + + /// + /// Enable OAuth 2.1 conformance assessment. + /// Default: true + /// + public bool EnableOAuth21Assessment { get; set; } = true; + + /// + /// Enable FAPI 2.0 Security Profile conformance assessment. + /// Default: true + /// + public bool EnableFapi2SecurityAssessment { get; set; } = true; + + /// + /// Path prefix for conformance endpoints (without leading slash). + /// Default: "_duende" + /// + public string PathPrefix { get; set; } = "_duende"; + + /// + /// ASP.NET Core authorization policy name for the HTML report endpoint. + /// Default: "ConformanceReport" + /// + public string AuthorizationPolicyName { get; set; } = "ConformanceReport"; + + /// + /// Configures the authorization policy for the conformance report endpoint. + /// By default, requires an authenticated user. + /// + /// + /// + /// If set to null, the authorization policy will NOT be automatically registered. + /// In this case, you must manually register a policy with the name specified in + /// (default: "ConformanceReport"), or the endpoint + /// will fail at runtime with a "policy not found" error. + /// + /// + /// Setting to null is useful when you need to register the policy yourself with + /// custom logic that cannot be expressed through . + /// + /// + /// + /// // Default behavior (requires authenticated user): + /// options.ConfigureAuthorization = policy => policy.RequireAuthenticatedUser(); + /// + /// // Require specific role: + /// options.ConfigureAuthorization = policy => policy.RequireRole("Admin"); + /// + /// // Require multiple claims: + /// options.ConfigureAuthorization = policy => policy + /// .RequireRole("Admin") + /// .RequireClaim("department", "IT"); + /// + /// // Allow anonymous (not recommended for production): + /// options.ConfigureAuthorization = policy => { }; + /// + /// // Manual policy registration (set to null and register policy yourself): + /// options.ConfigureAuthorization = null; + /// // Then in your Startup/Program.cs: + /// // services.AddAuthorization(options => + /// // options.AddPolicy("ConformanceReport", policy => + /// // policy.Requirements.Add(new MyCustomRequirement()))); + /// + public Action? ConfigureAuthorization { get; set; } + = policy => policy.RequireAuthenticatedUser(); + + /// + /// Optional display name of the host company to include in the report. This is for personalization and has no effect on the assessment results. + /// + public string? HostCompanyName { get; set; } + + /// + /// Gets or sets the URL of the host company's logo. + /// + /// Set this property to a valid URI that points to the logo image to display the host company's + /// branding. If the value is null, no logo will be shown. + public Uri? HostCompanyLogoUrl { get; set; } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportSecretTypes.cs b/conformance-report/src/ConformanceReport/ConformanceReportSecretTypes.cs new file mode 100644 index 000000000..a1a298321 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportSecretTypes.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +internal static class ConformanceReportSecretTypes +{ + public const string SharedSecret = "SharedSecret"; + public const string X509CertificateThumbprint = "X509Thumbprint"; + public const string X509CertificateName = "X509Name"; + public const string X509CertificateBase64 = "X509CertificateBase64"; + public const string JsonWebKey = "JWK"; +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportServerOptions.cs b/conformance-report/src/ConformanceReport/ConformanceReportServerOptions.cs new file mode 100644 index 000000000..0b540dc5a --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportServerOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +/// +/// Represents server-level options for conformance assessment. +/// +internal sealed record ConformanceReportServerOptions +{ + public required bool PushedAuthorizationEndpointEnabled { get; init; } + + public required bool PushedAuthorizationRequired { get; init; } + + public required int PushedAuthorizationLifetime { get; init; } + + public required bool MutualTlsEnabled { get; init; } + + public required IReadOnlyCollection SupportedSigningAlgorithms { get; init; } + + public required TimeSpan JwtValidationClockSkew { get; init; } + + public required bool EmitIssuerIdentificationResponseParameter { get; init; } + + public required bool UseHttp303Redirects { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportServiceCollectionExtensions.cs b/conformance-report/src/ConformanceReport/ConformanceReportServiceCollectionExtensions.cs new file mode 100644 index 000000000..5dee54903 --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Configuration; +using Duende.ConformanceReport.Endpoints; +using Duende.ConformanceReport.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport; + +/// +/// Extension methods for adding conformance services to the DI container. +/// +public static class ConformanceReportServiceCollectionExtensions +{ + /// + /// Adds core conformance services to the service collection. + /// + public static IServiceCollection AddConformanceReport( + this IServiceCollection services, + Action? configure = null) + { + _ = services.AddOptions(); + + if (configure != null) + { + _ = services.Configure(configure); + } + + // Register HTTP context accessor if not already registered + services.TryAddSingleton(); + + // Register assessment service + _ = services.AddTransient(); + + // Register endpoint + _ = services.AddTransient(); + + // Register authorization policy configuration + _ = services.AddSingleton, + ConfigureConformanceReportAuthorizationPolicy>(); + + return services; + } +} diff --git a/conformance-report/src/ConformanceReport/ConformanceReportTokenUsage.cs b/conformance-report/src/ConformanceReport/ConformanceReportTokenUsage.cs new file mode 100644 index 000000000..1d9cde90d --- /dev/null +++ b/conformance-report/src/ConformanceReport/ConformanceReportTokenUsage.cs @@ -0,0 +1,10 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +internal enum ConformanceReportTokenUsage +{ + ReUse = 0, + OneTimeOnly = 1 +} diff --git a/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpoint.cs b/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpoint.cs new file mode 100644 index 000000000..4cc653be2 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpoint.cs @@ -0,0 +1,76 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text; +using Duende.ConformanceReport.Services; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Endpoints; + +/// +/// Endpoint for generating conformance assessment reports. +/// +internal sealed partial class ConformanceReportEndpoint +{ + private readonly ConformanceReportAssessmentService _assessmentService; + private readonly ConformanceReportOptions _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public ConformanceReportEndpoint( + ConformanceReportAssessmentService assessmentService, + IOptions options, + ILogger logger) + { + _assessmentService = assessmentService; + _options = options.Value; + _logger = logger; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Processing conformance HTML report request")] + private partial void LogProcessingRequest(); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Conformance endpoint accessed but feature is not enabled")] + private partial void LogFeatureNotEnabled(); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error generating conformance HTML report")] + private partial void LogReportGenerationError(Exception ex); + + /// + /// Processes requests for the HTML conformance report. + /// + public async Task GetHtmlReportAsync(HttpContext context, CancellationToken cancellationToken = default) + { + LogProcessingRequest(); + + if (!_options.Enabled) + { + LogFeatureNotEnabled(); + return Results.NotFound(); + } + + try + { + var report = await _assessmentService.GenerateReportAsync(cancellationToken: cancellationToken); + + using var slice = Duende.ConformanceReport.Slices.ConformanceReport.Create(report); + var sb = new StringBuilder(); + await using var writer = new System.IO.StringWriter(sb); +#pragma warning disable CA2016 // RenderAsync overload for TextWriter doesn't accept CancellationToken + await slice.RenderAsync(writer); +#pragma warning restore CA2016 + + return Results.Content(sb.ToString(), "text/html"); + } + catch (InvalidOperationException ex) + { + LogReportGenerationError(ex); + return Results.Problem( + title: "Error generating conformance report", + detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } + } +} diff --git a/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpointExtensions.cs b/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpointExtensions.cs new file mode 100644 index 000000000..f3e38d3f1 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Endpoints/ConformanceReportEndpointExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Endpoints; + +/// +/// Extension methods for mapping conformance assessment endpoints. +/// +public static class ConformanceReportEndpointExtensions +{ + /// + /// Maps the conformance assessment endpoints. + /// + /// The endpoint route builder. + /// A builder for configuring the endpoint group. + public static RouteGroupBuilder MapConformanceReport(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var options = endpoints.ServiceProvider.GetRequiredService>().Value; + var basePath = $"/{options.PathPrefix.Trim('/')}/{ConformanceReportConstants.FeaturePath}"; + + var group = endpoints.MapGroup(basePath); + + // HTML endpoint - requires custom authorization policy + _ = group.MapGet("", async (ConformanceReportEndpoint endpoint, HttpContext context, CancellationToken cancellationToken) => + await endpoint.GetHtmlReportAsync(context, cancellationToken)) + .RequireAuthorization(options.AuthorizationPolicyName) + .WithName("GetConformanceHtmlReport") + .WithDescription("Gets the conformance assessment report as an HTML page") + .Produces(StatusCodes.Status200OK, contentType: "text/html") + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + + return group; + } +} diff --git a/conformance-report/src/ConformanceReport/GlobalUsings.cs b/conformance-report/src/ConformanceReport/GlobalUsings.cs new file mode 100644 index 000000000..7572c2c43 --- /dev/null +++ b/conformance-report/src/ConformanceReport/GlobalUsings.cs @@ -0,0 +1,10 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// Global usings that Microsoft.NET.Sdk.Web would normally inject implicitly. +// Required because this project uses Microsoft.NET.Sdk (not .Web) with an explicit FrameworkReference. +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; diff --git a/conformance-report/src/ConformanceReport/IConformanceReportClientStore.cs b/conformance-report/src/ConformanceReport/IConformanceReportClientStore.cs new file mode 100644 index 000000000..4a5a4090d --- /dev/null +++ b/conformance-report/src/ConformanceReport/IConformanceReportClientStore.cs @@ -0,0 +1,9 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +internal interface IConformanceReportClientStore +{ + Task> GetAllClientsAsync(CancellationToken ct = default); +} diff --git a/conformance-report/src/ConformanceReport/Models/ClientResult.cs b/conformance-report/src/ConformanceReport/Models/ClientResult.cs new file mode 100644 index 000000000..1aa4f429a --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ClientResult.cs @@ -0,0 +1,32 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents the conformance assessment results for a single client. +/// +public sealed class ClientResult +{ + /// + /// Initializes a new instance of the class. + /// + internal ClientResult() { } + + public required string ClientId { get; init; } + + /// + /// The client name, if available. + /// + public string? ClientName { get; internal init; } + + /// + /// The overall conformance status for this client. + /// + public required ConformanceReportStatus Status { get; init; } + + /// + /// The list of findings for this client. + /// + public required IReadOnlyList Findings { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ConformanceReportProfile.cs b/conformance-report/src/ConformanceReport/Models/ConformanceReportProfile.cs new file mode 100644 index 000000000..117fa0dd2 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ConformanceReportProfile.cs @@ -0,0 +1,20 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents a conformance profile that can be assessed. +/// +internal enum ConformanceReportProfile +{ + /// + /// OAuth 2.1 specification profile + /// + OAuth21, + + /// + /// FAPI 2.0 Security Profile + /// + Fapi2Security +} diff --git a/conformance-report/src/ConformanceReport/Models/ConformanceReportProfiles.cs b/conformance-report/src/ConformanceReport/Models/ConformanceReportProfiles.cs new file mode 100644 index 000000000..bdc383767 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ConformanceReportProfiles.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Container for conformance profile results. +/// +public sealed class ConformanceReportProfiles +{ + /// + /// Initializes a new instance of the class. + /// + internal ConformanceReportProfiles() { } + + /// + /// OAuth 2.1 conformance assessment results. + /// + public ProfileResult? OAuth21 { get; internal init; } + + /// + /// FAPI 2.0 Security Profile conformance assessment results. + /// + public ProfileResult? Fapi2Security { get; internal init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ConformanceReportResult.cs b/conformance-report/src/ConformanceReport/Models/ConformanceReportResult.cs new file mode 100644 index 000000000..a9bee9ffe --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ConformanceReportResult.cs @@ -0,0 +1,70 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents a complete conformance assessment report. +/// +public sealed class ConformanceReportResult +{ + /// + /// Initializes a new instance of the class. + /// + internal ConformanceReportResult() { } + + /// + /// The unique identifier for this report type (for GRC tool integration). + /// + public string Id { get; internal init; } = ConformanceReportConstants.ReportId; + + /// + /// The display name for this report (for GRC tool integration). + /// + public string Name { get; internal init; } = ConformanceReportConstants.ReportName; + + /// + /// The license information (if available). + /// + public ConformanceReportLicenseInfo? License { get; init; } + + /// + /// The version of the Conformance Report tool. + /// + public required string Version { get; init; } + + /// + /// The absolute URL to the HTML report endpoint. + /// + public required Uri Url { get; init; } + + /// + /// The overall conformance status across all profiles. + /// + public required ConformanceReportStatus Status { get; init; } + + /// + /// The timestamp when this report was generated. + /// + public required DateTimeOffset AssessedAt { get; init; } + + /// + /// The results for each conformance profile. + /// + public required ConformanceReportProfiles Profiles { get; init; } + + /// + /// Overall summary statistics across all profiles. + /// + public required OverallSummary OverallSummary { get; init; } + + /// + /// The name of the hosting company (optional). + /// + public string? HostCompanyName { get; init; } + + /// + /// The URL to the hosting company's logo (optional). + /// + public Uri? HostCompanyLogoUrl { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ConformanceReportStatus.cs b/conformance-report/src/ConformanceReport/Models/ConformanceReportStatus.cs new file mode 100644 index 000000000..98e896c5a --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ConformanceReportStatus.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents the overall conformance status for a report, profile, server, or client. +/// +public enum ConformanceReportStatus +{ + /// + /// All requirements are satisfied. + /// + Pass, + + /// + /// Some recommendations are not followed, but no requirements are violated. + /// + Warn, + + /// + /// One or more requirements are not satisfied. + /// + Fail +} diff --git a/conformance-report/src/ConformanceReport/Models/Finding.cs b/conformance-report/src/ConformanceReport/Models/Finding.cs new file mode 100644 index 000000000..45badd3c0 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/Finding.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents a single conformance finding for a specific rule. +/// +public sealed class Finding +{ + /// + /// Initializes a new instance of the class. + /// + internal Finding() { } + + /// + /// The unique identifier for this rule (e.g., "S01", "C03"). + /// + public required string RuleId { get; init; } + + /// + /// A human-readable name for this rule. + /// + public required string RuleName { get; init; } + + /// + /// The status of this finding. + /// + public required FindingStatus Status { get; init; } + + /// + /// A detailed message explaining the finding. + /// + public required string Message { get; init; } + + /// + /// Optional recommendation for remediation when the status is Fail or Warning. + /// + public string? Recommendation { get; internal init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/FindingStatus.cs b/conformance-report/src/ConformanceReport/Models/FindingStatus.cs new file mode 100644 index 000000000..ea47d38e6 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/FindingStatus.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents the status of a conformance finding. +/// +public enum FindingStatus +{ + /// + /// The requirement is satisfied. + /// + Pass, + + /// + /// The requirement is not satisfied. + /// + Fail, + + /// + /// A potential issue was detected that may affect conformance. + /// + Warning, + + /// + /// The requirement is not applicable to this configuration. + /// + NotApplicable, + + /// + /// An error occurred while assessing this requirement. + /// + Error +} diff --git a/conformance-report/src/ConformanceReport/Models/OverallSummary.cs b/conformance-report/src/ConformanceReport/Models/OverallSummary.cs new file mode 100644 index 000000000..7d7f126cb --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/OverallSummary.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Overall summary statistics for the conformance report. +/// +public sealed class OverallSummary +{ + /// + /// Initializes a new instance of the class. + /// + internal OverallSummary() { } + + /// + /// The total number of clients assessed. + /// + public int TotalClients { get; internal init; } + + /// + /// Summary statistics for OAuth 2.1 profile. + /// + public required ProfileStatusSummary OAuth21 { get; init; } + + /// + /// Summary statistics for FAPI 2.0 Security Profile. + /// + public required ProfileStatusSummary Fapi2Security { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ProfileResult.cs b/conformance-report/src/ConformanceReport/Models/ProfileResult.cs new file mode 100644 index 000000000..3d006b643 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ProfileResult.cs @@ -0,0 +1,57 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents the conformance assessment results for a specific profile. +/// +public sealed class ProfileResult +{ + /// + /// Initializes a new instance of the class. + /// + internal ProfileResult() { } + + /// + /// The display name for this profile (e.g., "OAuth 2.1", "FAPI 2.0 Security Profile"). + /// + public required string Name { get; init; } + + /// + /// The specification version assessed (e.g., "draft-14", "1.0"). + /// + public required string SpecVersion { get; init; } + + /// + /// The specification status (e.g., "draft", "final"). + /// + public required string SpecStatus { get; init; } + + /// + /// Optional note about the profile (e.g., draft specification warning). + /// + public string? Note { get; internal init; } + + /// + /// The overall conformance status for this profile. + /// + public required ConformanceReportStatus Status { get; init; } + + /// + /// Server-level conformance results. + /// + public required ServerResult Server { get; init; } + + /// + /// Per-client conformance results. + /// + public required IReadOnlyList Clients { get; init; } + + /// + /// Summary statistics for this profile. + /// + public required ProfileSummary Summary { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ProfileStatusSummary.cs b/conformance-report/src/ConformanceReport/Models/ProfileStatusSummary.cs new file mode 100644 index 000000000..8b95a4bb2 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ProfileStatusSummary.cs @@ -0,0 +1,30 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Status summary for a specific conformance profile. +/// +public sealed class ProfileStatusSummary +{ + /// + /// Initializes a new instance of the class. + /// + internal ProfileStatusSummary() { } + + /// + /// The number of clients that pass all requirements. + /// + public int Passing { get; internal init; } + + /// + /// The number of clients that have warnings but no failures. + /// + public int Warning { get; internal init; } + + /// + /// The number of clients that fail one or more requirements. + /// + public int Failing { get; internal init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ProfileSummary.cs b/conformance-report/src/ConformanceReport/Models/ProfileSummary.cs new file mode 100644 index 000000000..5c88f1778 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ProfileSummary.cs @@ -0,0 +1,35 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Summary statistics for a conformance profile assessment. +/// +public sealed class ProfileSummary +{ + /// + /// Initializes a new instance of the class. + /// + internal ProfileSummary() { } + + /// + /// The total number of clients assessed. + /// + public int TotalClients { get; internal init; } + + /// + /// The number of clients that pass all requirements. + /// + public int PassingClients { get; internal init; } + + /// + /// The number of clients that have warnings but no failures. + /// + public int WarningClients { get; internal init; } + + /// + /// The number of clients that fail one or more requirements. + /// + public int FailingClients { get; internal init; } +} diff --git a/conformance-report/src/ConformanceReport/Models/ServerResult.cs b/conformance-report/src/ConformanceReport/Models/ServerResult.cs new file mode 100644 index 000000000..cb1189f58 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Models/ServerResult.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport.Models; + +/// +/// Represents the server-level conformance assessment results. +/// +public sealed class ServerResult +{ + /// + /// Initializes a new instance of the class. + /// + internal ServerResult() { } + + /// + /// The overall status of server-level conformance. + /// + public required ConformanceReportStatus Status { get; init; } + + /// + /// The list of server-level conformance findings. + /// + public required IReadOnlyList Findings { get; init; } +} diff --git a/conformance-report/src/ConformanceReport/Properties/launchSettings.json b/conformance-report/src/ConformanceReport/Properties/launchSettings.json new file mode 100644 index 000000000..b4c03dd43 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ConformanceReport": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54651;http://localhost:54652" + } + } +} \ No newline at end of file diff --git a/conformance-report/src/ConformanceReport/Services/ConformanceReportAssessmentService.cs b/conformance-report/src/ConformanceReport/Services/ConformanceReportAssessmentService.cs new file mode 100644 index 000000000..c511609b3 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Services/ConformanceReportAssessmentService.cs @@ -0,0 +1,299 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Reflection; +using Duende.ConformanceReport.Models; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Services; + +/// +/// Service for assessing configuration conformance against OAuth 2.1 and FAPI 2.0 profiles. +/// +internal class ConformanceReportAssessmentService +{ + private readonly ConformanceReportOptions _conformanceOptions; + private readonly IConformanceReportClientStore _clientStore; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ConformanceReportLicenseInfo? _licenseInfo; + private readonly OAuth21Assessor _oauth21Assessor; + private readonly Fapi2SecurityAssessor _fapi2SecurityAssessor; + + /// + /// Initializes a new instance of the class. + /// + public ConformanceReportAssessmentService( + IOptions conformanceOptions, + Func serverOptionsProvider, + IConformanceReportClientStore clientStore, + IHttpContextAccessor httpContextAccessor, + ConformanceReportLicenseInfo? licenseInfo = null) + { + _conformanceOptions = conformanceOptions.Value; + _clientStore = clientStore; + _httpContextAccessor = httpContextAccessor; + _licenseInfo = licenseInfo; + + var serverOptions = serverOptionsProvider(); + _oauth21Assessor = new OAuth21Assessor(serverOptions); + _fapi2SecurityAssessor = new Fapi2SecurityAssessor(serverOptions); + } + + /// + /// Generates a complete conformance assessment report. + /// + /// The cancellation token. + /// A conformance report containing the assessment results. + public async Task GenerateReportAsync(CancellationToken cancellationToken = default) + { + var clients = await _clientStore.GetAllClientsAsync(cancellationToken); + var clientList = clients.ToList(); + + ProfileResult? oauth21Result = null; + ProfileResult? fapi2Result = null; + + if (_conformanceOptions.EnableOAuth21Assessment) + { + oauth21Result = AssessOAuth21Profile(clientList); + } + + if (_conformanceOptions.EnableFapi2SecurityAssessment) + { + fapi2Result = AssessFapi2SecurityProfile(clientList); + } + + var overallStatus = DetermineOverallStatus(oauth21Result, fapi2Result); + var reportUrl = BuildReportUrl(); + + return new ConformanceReportResult + { + Version = GetVersion(), + License = _licenseInfo, + Url = reportUrl, + Status = overallStatus, + AssessedAt = DateTimeOffset.UtcNow, + Profiles = new ConformanceReportProfiles + { + OAuth21 = oauth21Result, + Fapi2Security = fapi2Result + }, + OverallSummary = BuildOverallSummary(clientList.Count, oauth21Result, fapi2Result), + HostCompanyName = _conformanceOptions.HostCompanyName, + HostCompanyLogoUrl = _conformanceOptions.HostCompanyLogoUrl + }; + } + + /// + /// Generates a conformance assessment report for a specific profile. + /// + /// The profile to assess. + /// The cancellation token. + /// A profile result containing the assessment findings. + public async Task AssessProfileAsync( + ConformanceReportProfile profile, + CancellationToken cancellationToken = default) + { + var clients = await _clientStore.GetAllClientsAsync(cancellationToken); + var clientList = clients.ToList(); + + return profile switch + { + ConformanceReportProfile.OAuth21 => AssessOAuth21Profile(clientList), + ConformanceReportProfile.Fapi2Security => AssessFapi2SecurityProfile(clientList), + _ => throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown conformance profile") + }; + } + + /// + /// Assesses a single client against a specific profile. + /// + /// The profile to assess against. + /// The client to assess. + /// A client result containing the assessment findings. + public ClientResult AssessClient(ConformanceReportProfile profile, ConformanceReportClient client) + { + var findings = profile switch + { + ConformanceReportProfile.OAuth21 => _oauth21Assessor.AssessClient(client), + ConformanceReportProfile.Fapi2Security => _fapi2SecurityAssessor.AssessClient(client), + _ => throw new ArgumentOutOfRangeException(nameof(profile), profile, "Unknown conformance profile") + }; + + var status = DetermineStatusFromFindings(findings); + + return new ClientResult + { + ClientId = client.ClientId, + ClientName = client.ClientName, + Status = status, + Findings = findings + }; + } + + private ProfileResult AssessOAuth21Profile(List clients) + { + var serverFindings = _oauth21Assessor.AssessServer(); + var clientResults = clients.Select(c => AssessClient(ConformanceReportProfile.OAuth21, c)).ToList(); + + var serverStatus = DetermineStatusFromFindings(serverFindings); + var overallStatus = DetermineProfileStatus(serverStatus, clientResults); + + return new ProfileResult + { + Name = "OAuth 2.1", + SpecVersion = "draft-14", + SpecStatus = "draft", + Note = "OAuth 2.1 is currently a draft specification. Assessment rules may change as the specification evolves.", + Status = overallStatus, + Server = new ServerResult + { + Status = serverStatus, + Findings = serverFindings + }, + Clients = clientResults, + Summary = BuildProfileSummary(clientResults) + }; + } + + private ProfileResult AssessFapi2SecurityProfile(List clients) + { + var serverFindings = _fapi2SecurityAssessor.AssessServer(); + var clientResults = clients.Select(c => AssessClient(ConformanceReportProfile.Fapi2Security, c)).ToList(); + + var serverStatus = DetermineStatusFromFindings(serverFindings); + var overallStatus = DetermineProfileStatus(serverStatus, clientResults); + + return new ProfileResult + { + Name = "FAPI 2.0 Security Profile", + SpecVersion = "1.0", + SpecStatus = "final", + Status = overallStatus, + Server = new ServerResult + { + Status = serverStatus, + Findings = serverFindings + }, + Clients = clientResults, + Summary = BuildProfileSummary(clientResults) + }; + } + + private static ConformanceReportStatus DetermineStatusFromFindings(IReadOnlyList findings) + { + if (findings.Any(f => f.Status == FindingStatus.Fail || f.Status == FindingStatus.Error)) + { + return ConformanceReportStatus.Fail; + } + + if (findings.Any(f => f.Status == FindingStatus.Warning)) + { + return ConformanceReportStatus.Warn; + } + + return ConformanceReportStatus.Pass; + } + + private static ConformanceReportStatus DetermineProfileStatus(ConformanceReportStatus serverStatus, List clientResults) + { + if (serverStatus == ConformanceReportStatus.Fail || clientResults.Any(c => c.Status == ConformanceReportStatus.Fail)) + { + return ConformanceReportStatus.Fail; + } + + if (serverStatus == ConformanceReportStatus.Warn || clientResults.Any(c => c.Status == ConformanceReportStatus.Warn)) + { + return ConformanceReportStatus.Warn; + } + + return ConformanceReportStatus.Pass; + } + + private static ConformanceReportStatus DetermineOverallStatus(ProfileResult? oauth21, ProfileResult? fapi2) + { + var statuses = new List(); + + if (oauth21 is not null) + { + statuses.Add(oauth21.Status); + } + + if (fapi2 is not null) + { + statuses.Add(fapi2.Status); + } + + if (statuses.Count == 0) + { + return ConformanceReportStatus.Pass; + } + + if (statuses.Any(s => s == ConformanceReportStatus.Fail)) + { + return ConformanceReportStatus.Fail; + } + + if (statuses.Any(s => s == ConformanceReportStatus.Warn)) + { + return ConformanceReportStatus.Warn; + } + + return ConformanceReportStatus.Pass; + } + + private static ProfileSummary BuildProfileSummary(List clientResults) => + new() + { + TotalClients = clientResults.Count, + PassingClients = clientResults.Count(c => c.Status == ConformanceReportStatus.Pass), + WarningClients = clientResults.Count(c => c.Status == ConformanceReportStatus.Warn), + FailingClients = clientResults.Count(c => c.Status == ConformanceReportStatus.Fail) + }; + + private static OverallSummary BuildOverallSummary(int totalClients, ProfileResult? oauth21, ProfileResult? fapi2) => + new() + { + TotalClients = totalClients, + OAuth21 = oauth21 is not null + ? new ProfileStatusSummary + { + Passing = oauth21.Summary.PassingClients, + Warning = oauth21.Summary.WarningClients, + Failing = oauth21.Summary.FailingClients + } + : new ProfileStatusSummary(), + Fapi2Security = fapi2 is not null + ? new ProfileStatusSummary + { + Passing = fapi2.Summary.PassingClients, + Warning = fapi2.Summary.WarningClients, + Failing = fapi2.Summary.FailingClients + } + : new ProfileStatusSummary() + }; + + private Uri BuildReportUrl() + { + var request = _httpContextAccessor.HttpContext?.Request; + if (request is null) + { + return new Uri("about:blank"); + } + + var pathPrefix = _conformanceOptions.PathPrefix.Trim('/'); + return new Uri($"{request.Scheme}://{request.Host}/{pathPrefix}/{ConformanceReportConstants.FeaturePath}"); + } + + private static string GetVersion() + { + var assembly = typeof(ConformanceReportResult).Assembly; + var version = assembly.GetCustomAttribute() + ?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Strip git hash if present (MinVer adds +hash) + var plusIndex = version.IndexOf('+', StringComparison.Ordinal); + return plusIndex > 0 ? version[..plusIndex] : version; + } +} diff --git a/conformance-report/src/ConformanceReport/Services/Fapi2SecurityAssessor.cs b/conformance-report/src/ConformanceReport/Services/Fapi2SecurityAssessor.cs new file mode 100644 index 000000000..5ce49a2c6 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Services/Fapi2SecurityAssessor.cs @@ -0,0 +1,685 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Models; + +namespace Duende.ConformanceReport.Services; + +/// +/// Assesses configuration against the FAPI 2.0 Security Profile specification. +/// See: https://openid.net/specs/fapi-security-profile-2_0-final.html +/// +internal class Fapi2SecurityAssessor(ConformanceReportServerOptions options) +{ + // FAPI 2.0 requires asymmetric algorithms only, PS256 or ES256 recommended + private static readonly HashSet Fapi2AllowedAlgorithms = new(StringComparer.OrdinalIgnoreCase) + { + SigningAlgorithms.RsaSsaPssSha256, + SigningAlgorithms.RsaSsaPssSha384, + SigningAlgorithms.RsaSsaPssSha512, + SigningAlgorithms.EcdsaSha256, + SigningAlgorithms.EcdsaSha384, + SigningAlgorithms.EcdsaSha512, + "PS256", + "PS384", + "PS512", + "ES256", + "ES384", + "ES512" + }; + + // Maximum PAR lifetime recommended by FAPI 2.0 (10 minutes) + private const int MaxParLifetimeSeconds = 600; + + // Maximum authorization code lifetime for FAPI 2.0 + private const int MaxAuthCodeLifetimeSeconds = 60; + + /// + /// Assesses server-level configuration against FAPI 2.0 Security Profile requirements. + /// + public IReadOnlyList AssessServer() + { + var findings = new List + { + // FS01: PAR must be supported and required + AssessParRequired(), + // FS02: Sender-constrained tokens must be required + AssessSenderConstrainedTokensRequired(), + // FS03: Only PS256 or ES256 signing algorithms + AssessSigningAlgorithms(), + // FS04: PAR lifetime should be 10 minutes or less + AssessParLifetime(), + // FS05: mTLS or DPoP must be supported + AssessSenderConstrainedTokenSupport(), + // FS06: Issuer identification response parameter required + AssessIssuerIdentification(), + // FS07: HTTP 303 redirects required + AssessHttp303Redirects(), + // FS08: PKCE is required (checked at client level, note server support) + new() + { + RuleId = "FS08", + RuleName = "PKCE Support", + Status = FindingStatus.Pass, + Message = "Server supports PKCE. Individual client PKCE requirements are assessed separately." + } + }; + + return findings; + } + + /// + /// Assesses a client's configuration against FAPI 2.0 Security Profile requirements. + /// + public IReadOnlyList AssessClient(ConformanceReportClient client) + { + var findings = new List + { + // FC01: Only authorization_code grant allowed (no implicit, hybrid, password) + AssessGrantType(client), + // FC02: Must be confidential client + AssessConfidentialClient(client), + // FC03: PKCE required with S256 + AssessPkceS256(client), + // FC04: PAR must be required + AssessClientParRequired(client), + // FC05: Sender-constrained tokens required (DPoP or mTLS) + AssessSenderConstrainedTokens(client), + // FC06: Private key JWT or mTLS for client authentication + AssessClientAuthentication(client), + // FC07: Authorization code lifetime <= 60 seconds + AssessAuthCodeLifetime(client), + // FC08: Refresh token rotation required if refresh tokens enabled + AssessRefreshTokenRotation(client), + // FC09: DPoP nonce required if using DPoP + AssessDPoPNonce(client), + // FC10: Explicit redirect URIs (no wildcards) + AssessExplicitRedirectUris(client), + // FC11: No access tokens via browser + AssessNoAccessTokensViaBrowser(client), + // FC12: Request object required (for highest security) + AssessRequestObject(client) + }; + + return findings; + } + + private Finding AssessParRequired() + { + var parEnabled = options.PushedAuthorizationEndpointEnabled; + var parRequired = options.PushedAuthorizationRequired; + + if (!parEnabled) + { + return new Finding + { + RuleId = "FS01", + RuleName = "PAR Endpoint", + Status = FindingStatus.Fail, + Message = "PAR endpoint is not enabled. FAPI 2.0 requires PAR.", + Recommendation = "Enable the PAR endpoint and require PAR globally or per-client." + }; + } + + return new Finding + { + RuleId = "FS01", + RuleName = "PAR Endpoint", + Status = parRequired ? FindingStatus.Pass : FindingStatus.Warning, + Message = parRequired + ? "PAR endpoint is enabled and required globally." + : "PAR endpoint is enabled but not required globally. FAPI 2.0 requires PAR for all authorization requests.", + Recommendation = parRequired ? null : "Set PushedAuthorization.Required = true or require PAR per-client." + }; + } + + private Finding AssessSenderConstrainedTokensRequired() + { + var mtlsEnabled = options.MutualTlsEnabled; + + return new Finding + { + RuleId = "FS02", + RuleName = "Sender-Constrained Token Requirement", + Status = mtlsEnabled ? FindingStatus.Pass : FindingStatus.Warning, + Message = mtlsEnabled + ? "mTLS is enabled at the server level for sender-constrained tokens." + : "mTLS is not enabled at the server level. DPoP can be configured per-client. FAPI 2.0 requires sender-constrained tokens.", + Recommendation = mtlsEnabled ? null : "Enable mTLS or ensure all clients require DPoP." + }; + } + + private Finding AssessSigningAlgorithms() + { + var algorithms = options.SupportedSigningAlgorithms; + var nonFapiAlgorithms = algorithms + .Where(a => !Fapi2AllowedAlgorithms.Contains(a)) + .ToList(); + + if (nonFapiAlgorithms.Count == 0) + { + return new Finding + { + RuleId = "FS03", + RuleName = "FAPI 2.0 Signing Algorithms", + Status = FindingStatus.Pass, + Message = "All signing algorithms are FAPI 2.0 compliant (PS256/384/512 or ES256/384/512)." + }; + } + + // Check if at least FAPI-compliant algorithms are present + var hasFapiAlgorithm = algorithms.Any(a => Fapi2AllowedAlgorithms.Contains(a)); + + return new Finding + { + RuleId = "FS03", + RuleName = "FAPI 2.0 Signing Algorithms", + Status = hasFapiAlgorithm ? FindingStatus.Warning : FindingStatus.Fail, + Message = hasFapiAlgorithm + ? $"Non-FAPI 2.0 algorithms are configured: {string.Join(", ", nonFapiAlgorithms)}. FAPI 2.0 recommends PS256 or ES256 only." + : $"No FAPI 2.0 compliant algorithms configured. Found: {string.Join(", ", algorithms)}.", + Recommendation = "Use only PS256, PS384, PS512, ES256, ES384, or ES512 algorithms for FAPI 2.0 conformance." + }; + } + + private Finding AssessParLifetime() + { + var lifetime = options.PushedAuthorizationLifetime; + + if (lifetime <= MaxParLifetimeSeconds) + { + return new Finding + { + RuleId = "FS04", + RuleName = "PAR Lifetime", + Status = FindingStatus.Pass, + Message = $"PAR lifetime is {lifetime} seconds, within the FAPI 2.0 recommended maximum of {MaxParLifetimeSeconds} seconds." + }; + } + + return new Finding + { + RuleId = "FS04", + RuleName = "PAR Lifetime", + Status = FindingStatus.Fail, + Message = $"PAR lifetime is {lifetime} seconds. FAPI 2.0 recommends a maximum of {MaxParLifetimeSeconds} seconds (10 minutes).", + Recommendation = $"Set PushedAuthorization.Lifetime to {MaxParLifetimeSeconds} or less." + }; + } + + private Finding AssessSenderConstrainedTokenSupport() + { + var mtlsEnabled = options.MutualTlsEnabled; + // DPoP is always available per-client + + if (mtlsEnabled) + { + return new Finding + { + RuleId = "FS05", + RuleName = "Sender-Constrained Token Mechanisms", + Status = FindingStatus.Pass, + Message = "mTLS is enabled. DPoP is also available per-client configuration." + }; + } + + return new Finding + { + RuleId = "FS05", + RuleName = "Sender-Constrained Token Mechanisms", + Status = FindingStatus.Pass, + Message = "DPoP is available for sender-constrained tokens via per-client configuration. mTLS is not enabled at server level." + }; + } + + private Finding AssessIssuerIdentification() => + new() + { + RuleId = "FS06", + RuleName = "Issuer Identification", + Status = options.EmitIssuerIdentificationResponseParameter ? FindingStatus.Pass : FindingStatus.Fail, + Message = options.EmitIssuerIdentificationResponseParameter + ? "Issuer identification response parameter (iss) is enabled." + : "Issuer identification response parameter is not enabled. FAPI 2.0 requires this for mix-up attack prevention.", + Recommendation = options.EmitIssuerIdentificationResponseParameter + ? null + : "Set EmitIssuerIdentificationResponseParameter = true." + }; + + private Finding AssessHttp303Redirects() => + new() + { + RuleId = "FS07", + RuleName = "HTTP 303 Redirects", + Status = options.UseHttp303Redirects ? FindingStatus.Pass : FindingStatus.Fail, + Message = options.UseHttp303Redirects + ? "HTTP 303 (See Other) redirects are enabled as required by FAPI 2.0." + : "HTTP 302 (Found) redirects are used. FAPI 2.0 Section 5.3.2.2 requires HTTP 303 to prevent POST data resubmission.", + Recommendation = options.UseHttp303Redirects ? null : "Set UseHttp303Redirects = true." + }; + + private static Finding AssessGrantType(ConformanceReportClient client) + { + // FAPI 2.0 only allows authorization_code (and client_credentials for service accounts) + var allowedGrants = new HashSet + { + ConformanceReportGrantTypes.AuthorizationCode, + ConformanceReportGrantTypes.ClientCredentials, + ConformanceReportGrantTypes.RefreshToken + }; + + var disallowedGrants = client.AllowedGrantTypes + .Where(g => !allowedGrants.Contains(g)) + .ToList(); + + if (disallowedGrants.Count == 0) + { + return new Finding + { + RuleId = "FC01", + RuleName = "FAPI 2.0 Grant Types", + Status = FindingStatus.Pass, + Message = $"Client uses FAPI 2.0 compliant grant types: {string.Join(", ", client.AllowedGrantTypes)}." + }; + } + + return new Finding + { + RuleId = "FC01", + RuleName = "FAPI 2.0 Grant Types", + Status = FindingStatus.Fail, + Message = $"Client uses non-FAPI 2.0 grant types: {string.Join(", ", disallowedGrants)}. FAPI 2.0 only allows authorization_code and client_credentials.", + Recommendation = "Remove implicit, hybrid, password, and device_code grants." + }; + } + + private static Finding AssessConfidentialClient(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC02", + RuleName = "Confidential Client", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + return new Finding + { + RuleId = "FC02", + RuleName = "Confidential Client", + Status = client.RequireClientSecret ? FindingStatus.Pass : FindingStatus.Fail, + Message = client.RequireClientSecret + ? "Client is configured as confidential (requires secret)." + : "Client is configured as public (no secret required). FAPI 2.0 requires confidential clients for authorization_code flow.", + Recommendation = client.RequireClientSecret ? null : "Set RequireClientSecret = true and configure appropriate client authentication." + }; + } + + private static Finding AssessPkceS256(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC03", + RuleName = "PKCE with S256", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + if (!client.RequirePkce) + { + return new Finding + { + RuleId = "FC03", + RuleName = "PKCE with S256", + Status = FindingStatus.Fail, + Message = "PKCE is not required. FAPI 2.0 mandates PKCE with S256.", + Recommendation = "Set RequirePkce = true and AllowPlainTextPkce = false." + }; + } + + if (client.AllowPlainTextPkce) + { + return new Finding + { + RuleId = "FC03", + RuleName = "PKCE with S256", + Status = FindingStatus.Fail, + Message = "Plain text PKCE is allowed. FAPI 2.0 requires S256 challenge method.", + Recommendation = "Set AllowPlainTextPkce = false." + }; + } + + return new Finding + { + RuleId = "FC03", + RuleName = "PKCE with S256", + Status = FindingStatus.Pass, + Message = "PKCE is required with S256 challenge method." + }; + } + + private Finding AssessClientParRequired(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC04", + RuleName = "PAR Required", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + var parRequired = client.RequirePushedAuthorization || options.PushedAuthorizationRequired; + + return new Finding + { + RuleId = "FC04", + RuleName = "PAR Required", + Status = parRequired ? FindingStatus.Pass : FindingStatus.Fail, + Message = parRequired + ? "PAR is required for this client." + : "PAR is not required. FAPI 2.0 mandates PAR for all authorization requests.", + Recommendation = parRequired ? null : "Set RequirePushedAuthorization = true on the client." + }; + } + + private static Finding AssessSenderConstrainedTokens(ConformanceReportClient client) + { + var hasMtlsSecrets = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.X509CertificateThumbprint || + s == ConformanceReportSecretTypes.X509CertificateName || + s == ConformanceReportSecretTypes.X509CertificateBase64); + var requiresDPoP = client.RequireDPoP; + + if (hasMtlsSecrets || requiresDPoP) + { + return new Finding + { + RuleId = "FC05", + RuleName = "Sender-Constrained Tokens", + Status = FindingStatus.Pass, + Message = $"Client uses sender-constrained tokens via {(requiresDPoP ? "DPoP" : "")}{(requiresDPoP && hasMtlsSecrets ? " and " : "")}{(hasMtlsSecrets ? "mTLS" : "")}." + }; + } + + return new Finding + { + RuleId = "FC05", + RuleName = "Sender-Constrained Tokens", + Status = FindingStatus.Fail, + Message = "Client does not use sender-constrained tokens. FAPI 2.0 requires mTLS or DPoP.", + Recommendation = "Set RequireDPoP = true or configure mTLS certificate authentication." + }; + } + + private static Finding AssessClientAuthentication(ConformanceReportClient client) + { + if (!client.RequireClientSecret) + { + return new Finding + { + RuleId = "FC06", + RuleName = "Secure Client Authentication", + Status = FindingStatus.Fail, + Message = "Client is public. FAPI 2.0 requires confidential clients with private_key_jwt or mTLS authentication.", + Recommendation = "Configure RequireClientSecret = true with private_key_jwt or mTLS." + }; + } + + var hasPrivateKeyJwt = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.JsonWebKey || + s == ConformanceReportSecretTypes.X509CertificateBase64); + + var hasMtls = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.X509CertificateThumbprint || + s == ConformanceReportSecretTypes.X509CertificateName); + + if (hasPrivateKeyJwt || hasMtls) + { + var methods = new List(); + if (hasPrivateKeyJwt) + { + methods.Add("private_key_jwt"); + } + + if (hasMtls) + { + methods.Add("mTLS"); + } + + return new Finding + { + RuleId = "FC06", + RuleName = "Secure Client Authentication", + Status = FindingStatus.Pass, + Message = $"Client uses FAPI 2.0 compliant authentication: {string.Join(", ", methods)}." + }; + } + + return new Finding + { + RuleId = "FC06", + RuleName = "Secure Client Authentication", + Status = FindingStatus.Fail, + Message = "Client uses shared secret authentication. FAPI 2.0 requires private_key_jwt or mTLS.", + Recommendation = "Configure client authentication using private_key_jwt (JsonWebKey secret) or mTLS (X509Certificate secret)." + }; + } + + private static Finding AssessAuthCodeLifetime(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC07", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + if (client.AuthorizationCodeLifetime <= MaxAuthCodeLifetimeSeconds) + { + return new Finding + { + RuleId = "FC07", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.Pass, + Message = $"Authorization code lifetime is {client.AuthorizationCodeLifetime} seconds, within the FAPI 2.0 maximum of {MaxAuthCodeLifetimeSeconds} seconds." + }; + } + + return new Finding + { + RuleId = "FC07", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.Fail, + Message = $"Authorization code lifetime is {client.AuthorizationCodeLifetime} seconds. FAPI 2.0 requires {MaxAuthCodeLifetimeSeconds} seconds or less.", + Recommendation = $"Set AuthorizationCodeLifetime = {MaxAuthCodeLifetimeSeconds}." + }; + } + + private static Finding AssessRefreshTokenRotation(ConformanceReportClient client) + { + if (!client.AllowOfflineAccess) + { + return new Finding + { + RuleId = "FC08", + RuleName = "Refresh Token Rotation", + Status = FindingStatus.NotApplicable, + Message = "Client does not support refresh tokens (AllowOfflineAccess = false)." + }; + } + + return new Finding + { + RuleId = "FC08", + RuleName = "Refresh Token Rotation", + Status = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly ? FindingStatus.Pass : FindingStatus.Fail, + Message = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly + ? "Refresh token rotation is enabled (one-time use)." + : "Refresh tokens are reusable. FAPI 2.0 requires refresh token rotation.", + Recommendation = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly + ? null + : "Set RefreshTokenUsage = TokenUsage.OneTimeOnly." + }; + } + + private static Finding AssessDPoPNonce(ConformanceReportClient client) + { + if (!client.RequireDPoP) + { + return new Finding + { + RuleId = "FC09", + RuleName = "DPoP Nonce", + Status = FindingStatus.NotApplicable, + Message = "Client does not require DPoP." + }; + } + + var nonceEnabled = client.DPoPValidationMode.HasFlag(ConformanceReportDPoPValidationMode.Nonce); + + return new Finding + { + RuleId = "FC09", + RuleName = "DPoP Nonce", + Status = nonceEnabled ? FindingStatus.Pass : FindingStatus.Fail, + Message = nonceEnabled + ? "DPoP nonce validation is enabled." + : "DPoP nonce validation is not enabled. FAPI 2.0 requires nonce for DPoP replay protection.", + Recommendation = nonceEnabled ? null : "Set DPoPValidationMode to include DPoPTokenExpirationValidationMode.Nonce." + }; + } + + private static Finding AssessExplicitRedirectUris(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC10", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + if (client.RedirectUris.Count == 0) + { + return new Finding + { + RuleId = "FC10", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Fail, + Message = "No redirect URIs configured.", + Recommendation = "Configure at least one explicit redirect URI." + }; + } + + var wildcardUris = client.RedirectUris.Where(u => u.Contains('*', StringComparison.Ordinal)).ToList(); + + if (wildcardUris.Count > 0) + { + return new Finding + { + RuleId = "FC10", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Fail, + Message = $"Wildcard redirect URIs detected: {string.Join(", ", wildcardUris)}. FAPI 2.0 requires exact URI matching.", + Recommendation = "Replace wildcards with explicit URIs." + }; + } + + return new Finding + { + RuleId = "FC10", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Pass, + Message = $"All {client.RedirectUris.Count} redirect URI(s) are explicit." + }; + } + + private static Finding AssessNoAccessTokensViaBrowser(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC11", + RuleName = "No Access Tokens via Browser", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + return new Finding + { + RuleId = "FC11", + RuleName = "No Access Tokens via Browser", + Status = client.AllowAccessTokensViaBrowser ? FindingStatus.Fail : FindingStatus.Pass, + Message = client.AllowAccessTokensViaBrowser + ? "Access tokens can be transmitted via the browser. FAPI 2.0 prohibits this." + : "Access tokens are not allowed via browser.", + Recommendation = client.AllowAccessTokensViaBrowser ? "Set AllowAccessTokensViaBrowser = false." : null + }; + } + + private Finding AssessRequestObject(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "FC12", + RuleName = "Request Object", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + // Request object is recommended but PAR can substitute for it + var parRequired = client.RequirePushedAuthorization || options.PushedAuthorizationRequired; + + if (client.RequireRequestObject) + { + return new Finding + { + RuleId = "FC12", + RuleName = "Request Object", + Status = FindingStatus.Pass, + Message = "Request object is required for this client." + }; + } + + if (parRequired) + { + return new Finding + { + RuleId = "FC12", + RuleName = "Request Object", + Status = FindingStatus.Pass, + Message = "PAR is required, which provides equivalent security to request objects." + }; + } + + return new Finding + { + RuleId = "FC12", + RuleName = "Request Object", + Status = FindingStatus.Warning, + Message = "Neither request object nor PAR is required. FAPI 2.0 recommends one of these for enhanced security.", + Recommendation = "Set RequireRequestObject = true or RequirePushedAuthorization = true." + }; + } +} diff --git a/conformance-report/src/ConformanceReport/Services/OAuth21Assessor.cs b/conformance-report/src/ConformanceReport/Services/OAuth21Assessor.cs new file mode 100644 index 000000000..d9469ee69 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Services/OAuth21Assessor.cs @@ -0,0 +1,658 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Models; + +namespace Duende.ConformanceReport.Services; + +/// +/// Assesses configuration against the OAuth 2.1 specification. +/// +internal class OAuth21Assessor(ConformanceReportServerOptions options) +{ + // Algorithms considered insecure for OAuth 2.1 + // OAuth 2.1 prohibits symmetric algorithms and deprecated algorithms + // Note: RS256 is acceptable in OAuth 2.1, but FAPI 2.0 requires PS256/ES256 + private static readonly HashSet InsecureAlgorithms = new(StringComparer.OrdinalIgnoreCase) + { + SigningAlgorithms.HmacSha256, + SigningAlgorithms.HmacSha384, + SigningAlgorithms.HmacSha512, + "HS256", + "HS384", + "HS512", + "none" + }; + + // Maximum recommended authorization code lifetime in seconds + private const int MaxRecommendedAuthCodeLifetime = 60; + + // Maximum reasonable clock skew in minutes + private const int MaxReasonableClockSkewMinutes = 5; + + /// + /// Assesses server-level configuration against OAuth 2.1 requirements. + /// + public IReadOnlyList AssessServer() + { + var findings = new List(); + + // S01: PKCE should be enabled (checked at client level, but we note server supports it) + findings.Add(new Finding + { + RuleId = "S01", + RuleName = "PKCE Support", + Status = FindingStatus.Pass, + Message = "Server supports PKCE. Individual client configurations are assessed separately." + }); + + // S02: Password grant should not be allowed (checked at client level) + findings.Add(new Finding + { + RuleId = "S02", + RuleName = "Resource Owner Password Grant Prohibition", + Status = FindingStatus.Pass, + Message = "Password grant usage is assessed at the client level." + }); + + // S03: PAR should be available + findings.Add(AssessParAvailability()); + + // S04: Sender-constrained tokens recommendation + findings.Add(AssessSenderConstrainedTokenSupport()); + + // S05: Signing algorithms must be secure + findings.Add(AssessSigningAlgorithms()); + + // S06: Clock skew should be reasonable + findings.Add(AssessClockSkew()); + + // S07: DPoP nonce recommendation + findings.Add(AssessDPoPNonceConfiguration()); + + // S08: HTTP 303 redirects + findings.Add(AssessHttp303Redirects()); + + return findings; + } + + /// + /// Assesses a client's configuration against OAuth 2.1 requirements. + /// + public IReadOnlyList AssessClient(ConformanceReportClient client) + { + var findings = new List(); + + // C01: Only authorization_code or client_credentials grants + findings.Add(AssessAllowedGrantTypes(client)); + + // C02: PKCE required + findings.Add(AssessPkceRequired(client)); + + // C03: No plain text PKCE + findings.Add(AssessNonPlainPkce(client)); + + // C04: Explicit redirect URIs + findings.Add(AssessExplicitRedirectUris(client)); + + // C05: Confidential clients should require client secret + findings.Add(AssessConfidentialClientSecret(client)); + + // C06: PAR required recommended + findings.Add(AssessParRequired(client)); + + // C07: Sender-constrained tokens recommended + findings.Add(AssessSenderConstrainedTokens(client)); + + // C08: Auth code lifetime should be short + findings.Add(AssessAuthCodeLifetime(client)); + + // C09: Refresh token rotation recommended + findings.Add(AssessRefreshTokenRotation(client)); + + // C10: DPoP nonce if DPoP required + findings.Add(AssessDPoPNonce(client)); + + // C11: Private key JWT or mTLS for confidential clients + findings.Add(AssessClientAuthentication(client)); + + // C12: Refresh tokens recommended for authorization_code clients + findings.Add(AssessRefreshTokenSupport(client)); + + return findings; + } + + #region Server-Level Assessments + + private Finding AssessParAvailability() + { + var parEnabled = options.PushedAuthorizationEndpointEnabled; + + return new Finding + { + RuleId = "S03", + RuleName = "Pushed Authorization Requests (PAR)", + Status = parEnabled ? FindingStatus.Pass : FindingStatus.Warning, + Message = parEnabled + ? "PAR endpoint is enabled." + : "PAR endpoint is not enabled. PAR is recommended by OAuth 2.1 for enhanced security.", + Recommendation = parEnabled ? null : "Enable the PAR endpoint in EndpointsOptions." + }; + } + + private Finding AssessSenderConstrainedTokenSupport() + { + var mtlsEnabled = options.MutualTlsEnabled; + var dpopSupported = true; // DPoP is always supported when enabled per-client + + if (mtlsEnabled || dpopSupported) + { + return new Finding + { + RuleId = "S04", + RuleName = "Sender-Constrained Token Support", + Status = FindingStatus.Pass, + Message = $"Sender-constrained token mechanisms available: {(mtlsEnabled ? "mTLS" : "")}{(mtlsEnabled && dpopSupported ? ", " : "")}{(dpopSupported ? "DPoP" : "")}".TrimEnd(',', ' ') + }; + } + + return new Finding + { + RuleId = "S04", + RuleName = "Sender-Constrained Token Support", + Status = FindingStatus.Warning, + Message = "No sender-constrained token mechanisms (mTLS or DPoP) are configured at the server level.", + Recommendation = "Consider enabling mTLS or configuring DPoP for clients to support sender-constrained tokens." + }; + } + + private Finding AssessSigningAlgorithms() + { + var algorithms = options.SupportedSigningAlgorithms; + var insecureFound = algorithms.Where(a => InsecureAlgorithms.Contains(a)).ToList(); + + if (insecureFound.Count == 0) + { + return new Finding + { + RuleId = "S05", + RuleName = "Secure Signing Algorithms", + Status = FindingStatus.Pass, + Message = "All configured signing algorithms are considered secure." + }; + } + + return new Finding + { + RuleId = "S05", + RuleName = "Secure Signing Algorithms", + Status = FindingStatus.Fail, + Message = $"Insecure signing algorithms are configured: {string.Join(", ", insecureFound)}. OAuth 2.1 requires asymmetric algorithms.", + Recommendation = "Remove symmetric (HS*) and deprecated algorithms. Use RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, or ES512." + }; + } + + private Finding AssessClockSkew() + { + var clockSkew = options.JwtValidationClockSkew; + + if (clockSkew <= TimeSpan.FromMinutes(MaxReasonableClockSkewMinutes)) + { + return new Finding + { + RuleId = "S06", + RuleName = "JWT Clock Skew", + Status = FindingStatus.Pass, + Message = $"JWT clock skew is set to {clockSkew.TotalMinutes} minutes, which is within the recommended range." + }; + } + + return new Finding + { + RuleId = "S06", + RuleName = "JWT Clock Skew", + Status = FindingStatus.Warning, + Message = $"JWT clock skew is set to {clockSkew.TotalMinutes} minutes, which exceeds the recommended maximum of {MaxReasonableClockSkewMinutes} minutes.", + Recommendation = $"Consider reducing JwtValidationClockSkew to {MaxReasonableClockSkewMinutes} minutes or less." + }; + } + + // DPoP nonce configuration is set per-client via DPoPValidationMode + // At server level, we just note that DPoP is supported and nonce can be configured per-client + private static Finding AssessDPoPNonceConfiguration() => + new() + { + RuleId = "S07", + RuleName = "DPoP Nonce Support", + Status = FindingStatus.Pass, + Message = "DPoP is supported. Nonce validation can be configured per-client via DPoPValidationMode. Individual client configurations are assessed separately." + }; + + private Finding AssessHttp303Redirects() => + new() + { + RuleId = "S08", + RuleName = "HTTP 303 Redirects", + Status = options.UseHttp303Redirects ? FindingStatus.Pass : FindingStatus.Warning, + Message = options.UseHttp303Redirects + ? "HTTP 303 (See Other) redirects are enabled." + : "HTTP 302 (Found) redirects are used. HTTP 303 is recommended to prevent POST data resubmission.", + Recommendation = options.UseHttp303Redirects ? null : "Set UseHttp303Redirects = true in IdentityServerOptions." + }; + + #endregion + + #region Client-Level Assessments + + private static Finding AssessAllowedGrantTypes(ConformanceReportClient client) + { + var allowedGrants = new HashSet + { + ConformanceReportGrantTypes.AuthorizationCode, + ConformanceReportGrantTypes.ClientCredentials, + ConformanceReportGrantTypes.RefreshToken + }; + + var disallowedGrants = client.AllowedGrantTypes + .Where(g => !allowedGrants.Contains(g)) + .ToList(); + + if (disallowedGrants.Count == 0) + { + return new Finding + { + RuleId = "C01", + RuleName = "OAuth 2.1 Grant Types", + Status = FindingStatus.Pass, + Message = $"Client uses only OAuth 2.1 compliant grant types: {string.Join(", ", client.AllowedGrantTypes)}." + }; + } + + return new Finding + { + RuleId = "C01", + RuleName = "OAuth 2.1 Grant Types", + Status = FindingStatus.Fail, + Message = $"Client uses non-OAuth 2.1 grant types: {string.Join(", ", disallowedGrants)}. OAuth 2.1 only allows authorization_code, client_credentials, and refresh_token.", + Recommendation = "Remove implicit and password grants. Use authorization_code with PKCE for user authentication." + }; + } + + private static Finding AssessPkceRequired(ConformanceReportClient client) + { + // PKCE is only applicable to authorization_code grant + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C02", + RuleName = "PKCE Required", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant, so PKCE is not applicable." + }; + } + + return new Finding + { + RuleId = "C02", + RuleName = "PKCE Required", + Status = client.RequirePkce ? FindingStatus.Pass : FindingStatus.Fail, + Message = client.RequirePkce + ? "PKCE is required for this client." + : "PKCE is not required. OAuth 2.1 mandates PKCE for all authorization_code clients.", + Recommendation = client.RequirePkce ? null : "Set RequirePkce = true on the client." + }; + } + + private static Finding AssessNonPlainPkce(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C03", + RuleName = "No Plain Text PKCE", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant, so PKCE settings are not applicable." + }; + } + + return new Finding + { + RuleId = "C03", + RuleName = "No Plain Text PKCE", + Status = client.AllowPlainTextPkce ? FindingStatus.Fail : FindingStatus.Pass, + Message = client.AllowPlainTextPkce + ? "Plain text PKCE is allowed. OAuth 2.1 requires S256 challenge method." + : "Plain text PKCE is not allowed; S256 challenge method is enforced.", + Recommendation = client.AllowPlainTextPkce ? "Set AllowPlainTextPkce = false on the client." : null + }; + } + + private static Finding AssessExplicitRedirectUris(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C04", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant, so redirect URI validation is not applicable." + }; + } + + if (client.RedirectUris.Count == 0) + { + return new Finding + { + RuleId = "C04", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Fail, + Message = "No redirect URIs are configured. At least one explicit redirect URI is required.", + Recommendation = "Configure at least one explicit redirect URI for the client." + }; + } + + var wildcardUris = client.RedirectUris + .Where(u => u.Contains('*', StringComparison.Ordinal)) + .ToList(); + + if (wildcardUris.Count > 0) + { + return new Finding + { + RuleId = "C04", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Fail, + Message = $"Wildcard redirect URIs detected: {string.Join(", ", wildcardUris)}. OAuth 2.1 requires exact URI matching.", + Recommendation = "Replace wildcard redirect URIs with explicit, fully-qualified URIs." + }; + } + + return new Finding + { + RuleId = "C04", + RuleName = "Explicit Redirect URIs", + Status = FindingStatus.Pass, + Message = $"All {client.RedirectUris.Count} redirect URI(s) are explicit with no wildcards." + }; + } + + private static Finding AssessConfidentialClientSecret(ConformanceReportClient client) + { + // Public clients (authorization_code without secret) are allowed in OAuth 2.1 + // but they must use PKCE + if (!client.RequireClientSecret) + { + // This is a public client - check if it's using authorization_code with PKCE + if (client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode) && client.RequirePkce) + { + return new Finding + { + RuleId = "C05", + RuleName = "Client Authentication", + Status = FindingStatus.Pass, + Message = "Public client using authorization_code with PKCE, which is permitted by OAuth 2.1." + }; + } + + return new Finding + { + RuleId = "C05", + RuleName = "Client Authentication", + Status = FindingStatus.Warning, + Message = "Client does not require a secret. Consider whether this client should be confidential.", + Recommendation = "For confidential clients, set RequireClientSecret = true and configure client secrets." + }; + } + + // Confidential client - should have secrets + if (client.ClientSecretTypes.Count == 0) + { + return new Finding + { + RuleId = "C05", + RuleName = "Client Authentication", + Status = FindingStatus.Fail, + Message = "Confidential client (RequireClientSecret = true) has no secrets configured.", + Recommendation = "Add client secrets or use private_key_jwt/mTLS for authentication." + }; + } + + return new Finding + { + RuleId = "C05", + RuleName = "Client Authentication", + Status = FindingStatus.Pass, + Message = "Confidential client has secrets configured." + }; + } + + private Finding AssessParRequired(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C06", + RuleName = "PAR Required", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant, so PAR is not applicable." + }; + } + + var parRequired = client.RequirePushedAuthorization || options.PushedAuthorizationRequired; + + return new Finding + { + RuleId = "C06", + RuleName = "PAR Required", + Status = parRequired ? FindingStatus.Pass : FindingStatus.Warning, + Message = parRequired + ? "PAR is required for this client." + : "PAR is not required. Consider requiring PAR for enhanced security.", + Recommendation = parRequired ? null : "Set RequirePushedAuthorization = true on the client." + }; + } + + private static Finding AssessSenderConstrainedTokens(ConformanceReportClient client) + { + // Check for mTLS or DPoP + var hasMtlsSecrets = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.X509CertificateThumbprint || + s == ConformanceReportSecretTypes.X509CertificateName || + s == ConformanceReportSecretTypes.X509CertificateBase64); + var requiresDPoP = client.RequireDPoP; + + if (hasMtlsSecrets || requiresDPoP) + { + return new Finding + { + RuleId = "C07", + RuleName = "Sender-Constrained Tokens", + Status = FindingStatus.Pass, + Message = $"Client uses sender-constrained tokens via {(requiresDPoP ? "DPoP" : "")}{(requiresDPoP && hasMtlsSecrets ? " and " : "")}{(hasMtlsSecrets ? "mTLS" : "")}." + }; + } + + return new Finding + { + RuleId = "C07", + RuleName = "Sender-Constrained Tokens", + Status = FindingStatus.Warning, + Message = "Client does not use sender-constrained tokens (mTLS or DPoP).", + Recommendation = "Consider requiring DPoP (RequireDPoP = true) or configuring mTLS certificate authentication." + }; + } + + private static Finding AssessAuthCodeLifetime(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C08", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + if (client.AuthorizationCodeLifetime <= MaxRecommendedAuthCodeLifetime) + { + return new Finding + { + RuleId = "C08", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.Pass, + Message = $"Authorization code lifetime is {client.AuthorizationCodeLifetime} seconds, which is within the recommended maximum of {MaxRecommendedAuthCodeLifetime} seconds." + }; + } + + return new Finding + { + RuleId = "C08", + RuleName = "Authorization Code Lifetime", + Status = FindingStatus.Warning, + Message = $"Authorization code lifetime is {client.AuthorizationCodeLifetime} seconds. OAuth 2.1 recommends a short lifetime (e.g., {MaxRecommendedAuthCodeLifetime} seconds).", + Recommendation = $"Consider reducing AuthorizationCodeLifetime to {MaxRecommendedAuthCodeLifetime} seconds or less." + }; + } + + private static Finding AssessRefreshTokenRotation(ConformanceReportClient client) + { + if (!client.AllowOfflineAccess) + { + return new Finding + { + RuleId = "C09", + RuleName = "Refresh Token Rotation", + Status = FindingStatus.NotApplicable, + Message = "Client does not support refresh tokens (AllowOfflineAccess = false)." + }; + } + + return new Finding + { + RuleId = "C09", + RuleName = "Refresh Token Rotation", + Status = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly ? FindingStatus.Pass : FindingStatus.Warning, + Message = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly + ? "Refresh token rotation is enabled (one-time use)." + : "Refresh tokens are reusable. OAuth 2.1 recommends one-time use refresh tokens.", + Recommendation = client.RefreshTokenUsage == ConformanceReportTokenUsage.OneTimeOnly + ? null + : "Set RefreshTokenUsage = TokenUsage.OneTimeOnly for refresh token rotation." + }; + } + + private static Finding AssessDPoPNonce(ConformanceReportClient client) + { + if (!client.RequireDPoP) + { + return new Finding + { + RuleId = "C10", + RuleName = "DPoP Nonce Validation", + Status = FindingStatus.NotApplicable, + Message = "Client does not require DPoP." + }; + } + + var nonceEnabled = client.DPoPValidationMode.HasFlag(ConformanceReportDPoPValidationMode.Nonce); + + return new Finding + { + RuleId = "C10", + RuleName = "DPoP Nonce Validation", + Status = nonceEnabled ? FindingStatus.Pass : FindingStatus.Warning, + Message = nonceEnabled + ? "DPoP nonce validation is enabled for this client." + : "DPoP nonce validation is not enabled. This is recommended for enhanced replay protection.", + Recommendation = nonceEnabled ? null : "Set DPoPValidationMode to include DPoPTokenExpirationValidationMode.Nonce." + }; + } + + private static Finding AssessClientAuthentication(ConformanceReportClient client) + { + if (!client.RequireClientSecret) + { + return new Finding + { + RuleId = "C11", + RuleName = "Secure Client Authentication", + Status = FindingStatus.NotApplicable, + Message = "Client is a public client (does not require a secret)." + }; + } + + // Check for private_key_jwt (JWT Bearer) or mTLS authentication + var hasPrivateKeyJwt = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.JsonWebKey || + s == ConformanceReportSecretTypes.X509CertificateBase64); + + var hasMtls = client.ClientSecretTypes.Any(s => + s == ConformanceReportSecretTypes.X509CertificateThumbprint || + s == ConformanceReportSecretTypes.X509CertificateName); + + if (hasPrivateKeyJwt || hasMtls) + { + var methods = new List(); + if (hasPrivateKeyJwt) + { + methods.Add("private_key_jwt"); + } + + if (hasMtls) + { + methods.Add("mTLS"); + } + + return new Finding + { + RuleId = "C11", + RuleName = "Secure Client Authentication", + Status = FindingStatus.Pass, + Message = $"Client uses secure authentication method(s): {string.Join(", ", methods)}." + }; + } + + // Client uses shared secret + return new Finding + { + RuleId = "C11", + RuleName = "Secure Client Authentication", + Status = FindingStatus.Warning, + Message = "Client uses shared secret authentication. OAuth 2.1 recommends private_key_jwt or mTLS for confidential clients.", + Recommendation = "Consider migrating to private_key_jwt or mTLS authentication for enhanced security." + }; + } + + private static Finding AssessRefreshTokenSupport(ConformanceReportClient client) + { + if (!client.AllowedGrantTypes.Contains(ConformanceReportGrantTypes.AuthorizationCode)) + { + return new Finding + { + RuleId = "C12", + RuleName = "Refresh Token Support", + Status = FindingStatus.NotApplicable, + Message = "Client does not use authorization_code grant." + }; + } + + return new Finding + { + RuleId = "C12", + RuleName = "Refresh Token Support", + Status = client.AllowOfflineAccess ? FindingStatus.Pass : FindingStatus.Warning, + Message = client.AllowOfflineAccess + ? "Refresh tokens are enabled for this client." + : "Refresh tokens are not enabled. Consider enabling for better user experience without compromising security.", + Recommendation = client.AllowOfflineAccess ? null : "Set AllowOfflineAccess = true to enable refresh tokens." + }; + } + + #endregion +} diff --git a/conformance-report/src/ConformanceReport/SigningAlgorithms.cs b/conformance-report/src/ConformanceReport/SigningAlgorithms.cs new file mode 100644 index 000000000..280bec081 --- /dev/null +++ b/conformance-report/src/ConformanceReport/SigningAlgorithms.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace Duende.ConformanceReport; + +/// +/// Signing algorithm constants used for conformance assessment. +/// These match the values from Microsoft.IdentityModel.Tokens.SecurityAlgorithms. +/// +internal static class SigningAlgorithms +{ + // HMAC algorithms (symmetric - considered insecure for OAuth 2.1) + public const string HmacSha256 = "http://www.w3.org/2001/04/xmldsig-more#hmac-sha256"; + public const string HmacSha384 = "http://www.w3.org/2001/04/xmldsig-more#hmac-sha384"; + public const string HmacSha512 = "http://www.w3.org/2001/04/xmldsig-more#hmac-sha512"; + + // RSA algorithms + public const string RsaSha256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + public const string RsaSha384 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384"; + public const string RsaSha512 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"; + + // RSA-PSS algorithms (FAPI 2.0 compliant) + public const string RsaSsaPssSha256 = "http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1"; + public const string RsaSsaPssSha384 = "http://www.w3.org/2007/05/xmldsig-more#sha384-rsa-MGF1"; + public const string RsaSsaPssSha512 = "http://www.w3.org/2007/05/xmldsig-more#sha512-rsa-MGF1"; + + // ECDSA algorithms (FAPI 2.0 compliant) + public const string EcdsaSha256 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"; + public const string EcdsaSha384 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384"; + public const string EcdsaSha512 = "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"; +} diff --git a/conformance-report/src/ConformanceReport/Slices/ConformanceReport.cshtml b/conformance-report/src/ConformanceReport/Slices/ConformanceReport.cshtml new file mode 100644 index 000000000..031a2c7f2 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/ConformanceReport.cshtml @@ -0,0 +1,883 @@ +@using Duende.ConformanceReport.Slices +@using System.Globalization +@inherits RazorSlice + + + + + + + Conformance Assessment Report + + + +
+ +
+
+
+ @if (!string.IsNullOrWhiteSpace(Model.HostCompanyName) || Model.HostCompanyLogoUrl is not null) + { +
+ @if (Model.HostCompanyLogoUrl is not null) + { + + } + @if (!string.IsNullOrWhiteSpace(Model.HostCompanyName)) + { + @Model.HostCompanyName + } +
+ } + Duende IdentityServer +
+
+ Document ID: @GenerateDocumentId() +
+
+
+

Conformance Assessment Report

+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
License@GetLicenseInfo()
Report URL@Model.Url.ToString()
Assessment Date@FormatDateTime(Model.AssessedAt, includeDate: true)
Tool Version@Model.Version
Profiles Assessed@GetProfilesAssessed()
+
+ + +
+

Report Summary

+
+
+ Overall Status: + @GetStatusText(Model.Status) +
+

+ This report presents the results of an automated conformance assessment of the OAuth 2.0 / OpenID Connect + authorization server configuration. The assessment evaluates server and client configurations against + industry security profiles and best practices. +

+
+
+ @Model.OverallSummary.TotalClients + Clients Assessed +
+ @if (Model.Profiles.OAuth21 is not null) + { +
+ @Model.Profiles.OAuth21.Summary.PassingClients / @Model.Profiles.OAuth21.Summary.TotalClients + OAuth 2.1 Passing +
+ } + @if (Model.Profiles.Fapi2Security is not null) + { +
+ @Model.Profiles.Fapi2Security.Summary.PassingClients / @Model.Profiles.Fapi2Security.Summary.TotalClients + FAPI 2.0 Passing +
+ } +
+
+
+ + + @if (Model.Profiles.OAuth21 is not null) + { + @await RenderPartialAsync(_ProfileResult.Create(Model.Profiles.OAuth21)) + } + @if (Model.Profiles.Fapi2Security is not null) + { + @await RenderPartialAsync(_ProfileResult.Create(Model.Profiles.Fapi2Security)) + } + + +
+ + +
+
+ + + +@functions { + private static string FormatDateTime( + DateTimeOffset timestamp, + bool includeDate = true) + { + // Always display in UTC + var format = includeDate + ? "MMMM d, yyyy HH:mm:ss" + : "HH:mm:ss"; + + return $"{timestamp.UtcDateTime.ToString(format, CultureInfo.InvariantCulture)} (UTC)"; + } + + private string GenerateDocumentId() + { + // Generate a reproducible document ID based on assessment timestamp in UTC + var timestamp = Model.AssessedAt.UtcDateTime; + return $"CR-{timestamp:yyyyMMdd}-{timestamp:HHmmss}"; + } + + private string GetProfilesAssessed() + { + var profiles = new List(); + if (Model.Profiles.OAuth21 is not null) profiles.Add("OAuth 2.1"); + if (Model.Profiles.Fapi2Security is not null) profiles.Add("FAPI 2.0 Security Profile"); + return profiles.Count > 0 ? string.Join(", ", profiles) : "None"; + } + + private string GetLicenseInfo() + { + var license = Model.License; + if (license is null || string.IsNullOrWhiteSpace(license.CompanyName)) + { + return "No license"; + } + + var parts = new List(4) { license.CompanyName }; + + if (!string.IsNullOrWhiteSpace(license.Edition)) + { + parts.Add(license.Edition); + } + + if (license.SerialNumber.HasValue) + { + parts.Add($"#{license.SerialNumber.Value.ToString(CultureInfo.InvariantCulture)}"); + } + + if (license.Expiration.HasValue) + { + parts.Add($"Expires {license.Expiration.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}"); + } + + return string.Join(" | ", parts); + } + + private string GetStatusClass(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "status-pass", + ConformanceReportStatus.Warn => "status-warn", + ConformanceReportStatus.Fail => "status-fail", + _ => "status-unknown" + }; + + private string GetStatusText(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "CONFORMANT", + ConformanceReportStatus.Warn => "CONFORMANT WITH WARNINGS", + ConformanceReportStatus.Fail => "NON-CONFORMANT", + _ => "UNKNOWN" + }; + + private IHtmlContent GetStyles() => new HtmlString(""" + :root { + --color-pass: #166534; + --color-pass-bg: #f0fdf4; + --color-pass-border: #86efac; + --color-warn: #854d0e; + --color-warn-bg: #fefce8; + --color-warn-border: #fde047; + --color-fail: #991b1b; + --color-fail-bg: #fef2f2; + --color-fail-border: #fca5a5; + --color-na: #6b7280; + --color-text: #111827; + --color-text-muted: #4b5563; + --color-border: #d1d5db; + --color-border-dark: #9ca3af; + --color-bg-header: #f9fafb; + --color-brand: #6e45af; + } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + body { + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Arial, sans-serif; + font-size: 11pt; + line-height: 1.5; + color: var(--color-text); + background: #fff; + } + + .document { + max-width: 1100px; + margin: 0 auto; + padding: 2rem; + } + + /* Document Header */ + .document-header { + margin-bottom: 2rem; + } + + .header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .header-brand { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .host-company-brand { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .host-company-logo { + max-height: 40px; + max-width: 200px; + object-fit: contain; + } + + .host-company-text { + font-size: 12pt; + font-weight: 600; + color: var(--color-text); + letter-spacing: 0.03em; + } + + .brand-text { + font-variant-caps: small-caps; + font-size: 14pt; + font-weight: 700; + color: var(--color-brand); + letter-spacing: 0.05em; + } + + .doc-id { + font-size: 10pt; + color: var(--color-text-muted); + font-family: 'Consolas', 'Courier New', monospace; + } + + .header-divider { + height: 2px; + background: var(--color-border-dark); + margin: 0.5rem 0; + } + + .document-title { + font-variant-caps: small-caps; + font-size: 20pt; + font-weight: 600; + text-align: center; + padding: 1rem 0; + color: var(--color-text); + } + + /* Document Info Table */ + .document-info { + margin-bottom: 2rem; + page-break-inside: avoid; + break-inside: avoid; + } + + .info-table { + width: 100%; + border-collapse: collapse; + font-size: 10pt; + } + + .info-table th, + .info-table td { + padding: 0.5rem 1rem; + text-align: left; + border: 1px solid var(--color-border); + } + + .info-table th { + width: 180px; + background: var(--color-bg-header); + font-weight: 600; + color: var(--color-text); + } + + .info-table td { + font-family: 'Consolas', 'Courier New', monospace; + } + + /* Report Summary */ + .report-summary { + margin-bottom: 2rem; + page-break-inside: avoid; + break-inside: avoid; + } + + .report-summary h2 { + font-size: 14pt; + font-weight: 600; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--color-border-dark); + } + + .summary-box { + padding: 1.5rem; + border: 2px solid; + } + + .summary-box.status-pass { + border-color: var(--color-pass-border); + background: var(--color-pass-bg); + } + + .summary-box.status-warn { + border-color: var(--color-warn-border); + background: var(--color-warn-bg); + } + + .summary-box.status-fail { + border-color: var(--color-fail-border); + background: var(--color-fail-bg); + } + + .summary-status { + margin-bottom: 1rem; + } + + .status-label { + font-weight: 600; + margin-right: 0.5rem; + } + + .status-value { + font-weight: 700; + font-size: 12pt; + } + + .status-pass .status-value { + color: var(--color-pass); + } + + .status-warn .status-value { + color: var(--color-warn); + } + + .status-fail .status-value { + color: var(--color-fail); + } + + .summary-description { + margin-bottom: 1rem; + color: var(--color-text-muted); + font-size: 10pt; + } + + .summary-stats { + display: flex; + gap: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); + } + + .stat { + display: flex; + flex-direction: column; + } + + .stat-value { + font-size: 14pt; + font-weight: 700; + color: var(--color-text); + } + + .stat-label { + font-size: 9pt; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + /* Section Headers */ + h2 { + font-size: 14pt; + font-weight: 600; + margin: 2rem 0 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--color-border-dark); + } + + h3 { + font-size: 11pt; + font-weight: 600; + margin: 1.5rem 0 0.75rem; + color: var(--color-text); + } + + /* Profile Section */ + .profile-section { + margin-bottom: 2rem; + page-break-inside: avoid; + break-inside: avoid; + } + + .profile-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.5rem; + } + + .profile-title { + font-size: 14pt; + font-weight: 600; + margin: 0; + padding: 0; + border: none; + } + + .profile-status { + font-weight: 700; + font-size: 10pt; + padding: 0.25rem 0.75rem; + border: 1px solid; + } + + .profile-status.status-pass { + color: var(--color-pass); + border-color: var(--color-pass); + background: var(--color-pass-bg); + } + + .profile-status.status-warn { + color: var(--color-warn); + border-color: var(--color-warn); + background: var(--color-warn-bg); + } + + .profile-status.status-fail { + color: var(--color-fail); + border-color: var(--color-fail); + background: var(--color-fail-bg); + } + + .profile-meta { + font-size: 9pt; + color: var(--color-text-muted); + margin-bottom: 1rem; + } + + .profile-note { + font-size: 9pt; + color: var(--color-text-muted); + font-style: italic; + margin-bottom: 1rem; + padding: 0.5rem; + background: var(--color-bg-header); + border-left: 3px solid var(--color-border-dark); + } + + /* Assessment Tables */ + .assessment-table { + width: 100%; + border-collapse: collapse; + font-size: 10pt; + margin-bottom: 1.5rem; + page-break-inside: auto; + break-inside: auto; + } + + .assessment-table th, + .assessment-table td { + padding: 0.5rem 0.75rem; + text-align: left; + border: 1px solid var(--color-border); + } + + .assessment-table tr { + page-break-inside: avoid; + break-inside: avoid; + } + + .assessment-table thead { + display: table-header-group; + } + + .assessment-table tbody { + display: table-row-group; + } + + .assessment-table thead th { + background: var(--color-bg-header); + font-weight: 600; + font-size: 9pt; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + .assessment-table .col-rule { + width: 60px; + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 600; + } + + .assessment-table .col-status { + width: 80px; + text-align: center; + font-weight: 600; + } + + .assessment-table .col-status.pass { + color: var(--color-pass); + } + + .assessment-table .col-status.warn { + color: var(--color-warn); + } + + .assessment-table .col-status.fail { + color: var(--color-fail); + } + + .assessment-table .col-status.na { + color: var(--color-na); + } + + /* Matrix Table */ + .matrix-section { + margin-bottom: 1.5rem; + overflow-x: auto; + page-break-inside: avoid; + break-inside: avoid; + } + + .matrix-table { + width: 100%; + border-collapse: collapse; + font-size: 9pt; + page-break-inside: auto; + break-inside: auto; + } + + .matrix-table th, + .matrix-table td { + padding: 0.5rem; + border: 1px solid var(--color-border); + text-align: center; + } + + .matrix-table tr { + page-break-inside: avoid; + break-inside: avoid; + } + + .matrix-table thead { + display: table-header-group; + } + + .matrix-table tbody { + display: table-row-group; + } + + .matrix-table thead th { + background: var(--color-bg-header); + font-weight: 600; + font-size: 8pt; + } + + .matrix-table .entity-col { + text-align: left; + min-width: 180px; + font-weight: 500; + } + + .matrix-table .entity-id { + display: block; + font-size: 8pt; + font-family: 'Consolas', 'Courier New', monospace; + color: var(--color-text-muted); + font-weight: 400; + } + + .matrix-table .cell-pass { + color: var(--color-pass); + font-weight: 600; + } + + .matrix-table .cell-warn { + color: var(--color-warn); + font-weight: 600; + } + + .matrix-table .cell-fail { + color: var(--color-fail); + font-weight: 600; + } + + .matrix-table .cell-na { + color: var(--color-na); + } + + .matrix-table .status-col { + font-weight: 700; + min-width: 70px; + } + + .matrix-table .ref-link { + font-size: 7pt; + color: var(--color-brand); + vertical-align: super; + text-decoration: none; + } + + /* Rule Legend */ + .rule-legend { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--color-bg-header); + border: 1px solid var(--color-border); + font-size: 9pt; + page-break-inside: avoid; + break-inside: avoid; + } + + .rule-legend h4 { + font-size: 10pt; + font-weight: 600; + margin-bottom: 0.75rem; + } + + .legend-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 0.25rem 1.5rem; + } + + .legend-item { + display: flex; + gap: 0.5rem; + padding: 0.2rem 0; + } + + .legend-code { + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 600; + min-width: 45px; + flex-shrink: 0; + } + + .legend-text { + color: var(--color-text-muted); + } + + /* Findings Section */ + .findings-section { + margin-bottom: 1.5rem; + page-break-inside: avoid; + break-inside: avoid; + } + + .findings-section h4 { + font-size: 11pt; + font-weight: 600; + margin-bottom: 0.75rem; + } + + .finding-group { + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid var(--color-border); + background: #fff; + page-break-inside: avoid; + break-inside: avoid; + } + + .finding-group-header { + font-weight: 600; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); + } + + .finding-list { + list-style: none; + padding: 0; + margin: 0; + } + + .finding-item { + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border); + font-size: 10pt; + } + + .finding-item:last-child { + border-bottom: none; + } + + .finding-number { + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 600; + color: var(--color-text-muted); + margin-right: 0.5rem; + } + + .finding-action { + font-weight: 600; + } + + .finding-detail { + display: block; + margin-left: 1.75rem; + color: var(--color-text-muted); + font-size: 9pt; + } + + /* Document Footer */ + .document-footer { + margin-top: 3rem; + } + + .footer-divider { + height: 2px; + background: var(--color-border-dark); + margin-bottom: 1rem; + } + + .footer-content { + display: flex; + justify-content: space-between; + font-size: 9pt; + color: var(--color-text-muted); + } + + .footer-left p, + .footer-right p { + margin: 0.15rem 0; + } + + .footer-right { + text-align: right; + } + + /* Print Styles */ + @media print { + body { + font-size: 10pt; + background: #fff; + } + + .document { + max-width: none; + padding: 0; + margin: 0; + } + + .profile-section { + page-break-inside: avoid; + } + + .assessment-table, + .matrix-table { + page-break-inside: auto; + } + + .assessment-table tr, + .matrix-table tr { + page-break-inside: avoid; + } + + .findings-section { + page-break-before: auto; + } + + .document-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 0.5rem 2rem; + background: #fff; + } + + a { + color: inherit; + text-decoration: none; + } + + a[href]::after { + content: none; + } + } + + @media (max-width: 768px) { + .document { + padding: 1rem; + } + + .header-row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .summary-stats { + flex-wrap: wrap; + } + + .legend-grid { + grid-template-columns: 1fr; + } + + .matrix-table { + font-size: 8pt; + } + + .footer-content { + flex-direction: column; + gap: 0.5rem; + } + + .footer-right { + text-align: left; + } + } + """); +} diff --git a/conformance-report/src/ConformanceReport/Slices/_ClientConfigurationMatrix.cshtml b/conformance-report/src/ConformanceReport/Slices/_ClientConfigurationMatrix.cshtml new file mode 100644 index 000000000..9223648e6 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_ClientConfigurationMatrix.cshtml @@ -0,0 +1,181 @@ +@inherits RazorSlice> +@{ + // Collect all unique rule IDs across all clients + var allRuleIds = Model + .SelectMany(c => c.Findings) + .Select(f => f.RuleId) + .Distinct() + .OrderBy(r => r) + .ToList(); + + // Create a dictionary for quick lookup of findings by client and rule + var findingsByClient = Model.ToDictionary( + c => c, + c => c.Findings.ToDictionary(f => f.RuleId) + ); + + // Collect footnotes (warnings and failures with messages/recommendations) + var footnotes = new List<(string ClientId, string ClientName, Finding Finding)>(); + var footnoteIndex = 1; + var footnoteMap = new Dictionary(); // key: clientId_ruleId, value: footnote number + + foreach (var client in Model) + { + foreach (var finding in client.Findings.Where(f => f.Status == FindingStatus.Fail || f.Status == FindingStatus.Warning)) + { + var key = $"{client.ClientId}_{finding.RuleId}"; + if (!footnoteMap.ContainsKey(key)) + { + footnoteMap[key] = footnoteIndex++; + footnotes.Add((client.ClientId, client.ClientName ?? client.ClientId, finding)); + } + } + } +} + +
+ + + + + @foreach (var ruleId in allRuleIds) + { + + } + + + + + @foreach (var client in Model) + { + var statusClass = GetStatusClass(client.Status); + var statusText = GetStatusText(client.Status); + var clientFindings = findingsByClient[client]; + var displayName = !string.IsNullOrEmpty(client.ClientName) + ? client.ClientName + : client.ClientId; + + + + @foreach (var ruleId in allRuleIds) + { + if (clientFindings.TryGetValue(ruleId, out var finding)) + { + var cellClass = GetCellClass(finding.Status); + var cellText = GetCellText(finding.Status); + var key = $"{client.ClientId}_{finding.RuleId}"; + var hasFootnote = footnoteMap.ContainsKey(key); + var title = $"{finding.RuleName}: {finding.Message}"; + + + } + else + { + + } + } + + + } + +
Client@ruleIdStatus
+ @displayName + @client.ClientId + + @cellText@if (hasFootnote) + {[@footnoteMap[key]]} + N/A@statusText
+
+ +@if (allRuleIds.Count > 0) +{ +
+

Rule Reference

+
+ @{ + // Get unique rules with their descriptions + var ruleDescriptions = Model + .SelectMany(c => c.Findings) + .GroupBy(f => f.RuleId) + .Select(g => g.First()) + .OrderBy(f => f.RuleId) + .ToList(); + } + @foreach (var finding in ruleDescriptions) + { +
+ @finding.RuleId + @finding.RuleName +
+ } +
+
+} + +@if (footnotes.Count > 0) +{ + // Group footnotes by client + var groupedByClient = footnotes + .GroupBy(f => new { f.ClientId, f.ClientName }) + .ToList(); + +
+

Client Configuration Findings

+ @foreach (var clientGroup in groupedByClient) + { +
+
@clientGroup.Key.ClientName (@clientGroup.Key.ClientId)
+
    + @foreach (var (clientId, clientName, finding) in clientGroup) + { + var key = $"{clientId}_{finding.RuleId}"; + var num = footnoteMap[key]; +
  • + @num. + @(!string.IsNullOrEmpty(finding.Recommendation) ? finding.Recommendation : finding.RuleName) + @finding.Message +
  • + } +
+
+ } +
+} + +@functions { + private string GetStatusClass(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "cell-pass", + ConformanceReportStatus.Warn => "cell-warn", + ConformanceReportStatus.Fail => "cell-fail", + _ => "" + }; + + private string GetCellClass(FindingStatus status) => status switch + { + FindingStatus.Pass => "cell-pass", + FindingStatus.Fail => "cell-fail", + FindingStatus.Warning => "cell-warn", + FindingStatus.NotApplicable => "cell-na", + FindingStatus.Error => "cell-fail", + _ => "" + }; + + private string GetStatusText(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "PASS", + ConformanceReportStatus.Warn => "WARN", + ConformanceReportStatus.Fail => "FAIL", + _ => "?" + }; + + private string GetCellText(FindingStatus status) => status switch + { + FindingStatus.Pass => "PASS", + FindingStatus.Fail => "FAIL", + FindingStatus.Warning => "WARN", + FindingStatus.NotApplicable => "N/A", + FindingStatus.Error => "ERR", + _ => "?" + }; +} diff --git a/conformance-report/src/ConformanceReport/Slices/_ClientResult.cshtml b/conformance-report/src/ConformanceReport/Slices/_ClientResult.cshtml new file mode 100644 index 000000000..f11766629 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_ClientResult.cshtml @@ -0,0 +1,23 @@ +@using Duende.ConformanceReport.Slices +@inherits RazorSlice +@{ + var statusClass = GetStatusClass(Model.Status); + var displayName = !string.IsNullOrEmpty(Model.ClientName) + ? $"{Model.ClientName} ({Model.ClientId})" + : Model.ClientId; +} + +
+ @displayName + @await RenderPartialAsync(_FindingsTable.Create(Model.Findings)) +
+ +@functions { + private string GetStatusClass(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "pass", + ConformanceReportStatus.Warn => "warning", + ConformanceReportStatus.Fail => "fail", + _ => "unknown" + }; +} diff --git a/conformance-report/src/ConformanceReport/Slices/_FindingsTable.cshtml b/conformance-report/src/ConformanceReport/Slices/_FindingsTable.cshtml new file mode 100644 index 000000000..9a5f1f77c --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_FindingsTable.cshtml @@ -0,0 +1,50 @@ +@inherits RazorSlice> + + + + + + + + + + + + + @foreach (var finding in Model) + { + var statusClass = GetStatusClass(finding.Status); + var statusText = GetStatusText(finding.Status); + + + + + + + + + } + +
RuleNameStatusMessageRecommendation
@finding.RuleId@finding.RuleName@statusText@finding.Message@(finding.Recommendation ?? "-")
+ +@functions { + private string GetStatusClass(FindingStatus status) => status switch + { + FindingStatus.Pass => "pass", + FindingStatus.Fail => "fail", + FindingStatus.Warning => "warning", + FindingStatus.NotApplicable => "na", + FindingStatus.Error => "error", + _ => "unknown" + }; + + private string GetStatusText(FindingStatus status) => status switch + { + FindingStatus.Pass => "PASS", + FindingStatus.Fail => "FAIL", + FindingStatus.Warning => "WARNING", + FindingStatus.NotApplicable => "N/A", + FindingStatus.Error => "ERROR", + _ => "UNKNOWN" + }; +} diff --git a/conformance-report/src/ConformanceReport/Slices/_ProfileResult.cshtml b/conformance-report/src/ConformanceReport/Slices/_ProfileResult.cshtml new file mode 100644 index 000000000..c32b50be0 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_ProfileResult.cshtml @@ -0,0 +1,54 @@ +@using Duende.ConformanceReport.Slices +@inherits RazorSlice +@{ + var statusClass = GetStatusClass(Model.Status); + var statusText = GetStatusText(Model.Status); +} + +
+
+

@Model.Name

+ @statusText +
+ +

+ Specification Version: @Model.SpecVersion (@Model.SpecStatus) +

+ + @if (!string.IsNullOrEmpty(Model.Note)) + { +

@Model.Note

+ } + +
+

Server Configuration Assessment

+ @await RenderPartialAsync(_ServerConfigurationMatrix.Create(Model.Server.Findings)) +
+ + @if (Model.Clients.Count > 0) + { +
+

Client Configuration Assessment

+ @await RenderPartialAsync(_ClientConfigurationMatrix.Create(Model.Clients)) +
+ } +
+ +@functions { + + private string GetStatusClass(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "status-pass", + ConformanceReportStatus.Warn => "status-warn", + ConformanceReportStatus.Fail => "status-fail", + _ => "status-unknown" + }; + + private string GetStatusText(ConformanceReportStatus status) => status switch + { + ConformanceReportStatus.Pass => "PASS", + ConformanceReportStatus.Warn => "WARN", + ConformanceReportStatus.Fail => "FAIL", + _ => "UNKNOWN" + }; +} diff --git a/conformance-report/src/ConformanceReport/Slices/_ServerConfigurationMatrix.cshtml b/conformance-report/src/ConformanceReport/Slices/_ServerConfigurationMatrix.cshtml new file mode 100644 index 000000000..907fa4b11 --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_ServerConfigurationMatrix.cshtml @@ -0,0 +1,129 @@ +@inherits RazorSlice> +@{ + // Collect all findings ordered by rule ID + var findings = Model.OrderBy(f => f.RuleId).ToList(); + + // Collect footnotes (warnings and failures with messages/recommendations) + var footnotes = new List(); + var footnoteIndex = 1; + var footnoteMap = new Dictionary(); // key: ruleId, value: footnote number + + foreach (var finding in findings.Where(f => f.Status == FindingStatus.Fail || f.Status == FindingStatus.Warning)) + { + if (!footnoteMap.ContainsKey(finding.RuleId)) + { + footnoteMap[finding.RuleId] = footnoteIndex++; + footnotes.Add(finding); + } + } +} + +
+ + + + + @foreach (var finding in findings) + { + + } + + + + + + + @foreach (var finding in findings) + { + var cellClass = GetCellClass(finding.Status); + var statusText = GetCellText(finding.Status); + var hasFootnote = footnoteMap.ContainsKey(finding.RuleId); + var title = $"{finding.RuleName}: {finding.Message}"; + + + } + + + +
Component@finding.RuleIdStatus
Authorization Server + @statusText@if (hasFootnote) + {[@footnoteMap[finding.RuleId]]} + @GetOverallStatusText(findings)
+
+ +@if (findings.Count > 0) +{ +
+

Rule Reference

+
+ @foreach (var finding in findings) + { +
+ @finding.RuleId + @finding.RuleName +
+ } +
+
+} + +@if (footnotes.Count > 0) +{ +
+

Server Configuration Findings

+
+
    + @{ var findingNum = 1; } + @foreach (var finding in footnotes) + { +
  • + @findingNum. + @(!string.IsNullOrEmpty(finding.Recommendation) ? finding.Recommendation : finding.RuleName) + @finding.Message +
  • + findingNum++; + } +
+
+
+} + +@functions { + private string GetCellClass(FindingStatus status) => status switch + { + FindingStatus.Pass => "cell-pass", + FindingStatus.Fail => "cell-fail", + FindingStatus.Warning => "cell-warn", + FindingStatus.NotApplicable => "cell-na", + FindingStatus.Error => "cell-fail", + _ => "" + }; + + private string GetCellText(FindingStatus status) => status switch + { + FindingStatus.Pass => "PASS", + FindingStatus.Fail => "FAIL", + FindingStatus.Warning => "WARN", + FindingStatus.NotApplicable => "N/A", + FindingStatus.Error => "ERR", + _ => "?" + }; + + private string GetOverallStatusClass(List findings) + { + if (findings.Any(f => f.Status == FindingStatus.Fail || f.Status == FindingStatus.Error)) + return "cell-fail"; + if (findings.Any(f => f.Status == FindingStatus.Warning)) + return "cell-warn"; + return "cell-pass"; + } + + private string GetOverallStatusText(List findings) + { + if (findings.Any(f => f.Status == FindingStatus.Fail || f.Status == FindingStatus.Error)) + return "FAIL"; + if (findings.Any(f => f.Status == FindingStatus.Warning)) + return "WARN"; + return "PASS"; + } +} diff --git a/conformance-report/src/ConformanceReport/Slices/_ViewImports.cshtml b/conformance-report/src/ConformanceReport/Slices/_ViewImports.cshtml new file mode 100644 index 000000000..847832b3d --- /dev/null +++ b/conformance-report/src/ConformanceReport/Slices/_ViewImports.cshtml @@ -0,0 +1,10 @@ +@using System.Globalization +@using System.Linq +@using Duende.ConformanceReport.Models +@using Duende.RazorSlices +@using Microsoft.AspNetCore.Html + +@inherits RazorSlice + +@* Disable tag helpers as they're not needed for this use case *@ +@removeTagHelper *, Microsoft.AspNetCore.Mvc.Razor diff --git a/conformance-report/src/Directory.Build.props b/conformance-report/src/Directory.Build.props new file mode 100644 index 000000000..7074fd1f9 --- /dev/null +++ b/conformance-report/src/Directory.Build.props @@ -0,0 +1,13 @@ + + + + enable + Duende.ConformanceReport.$(MSBuildProjectName) + Duende.ConformanceReport.$(MSBuildProjectName) + Duende.ConformanceReport + 0.1 + cr- + OAuth 2.0;OpenID Connect;Security;Identity;IdentityServer;Conformance + Duende Conformance Report + + diff --git a/conformance-report/test/ConformanceReport.Tests/Configuration/AuthorizationConfigurationTests.cs b/conformance-report/test/ConformanceReport.Tests/Configuration/AuthorizationConfigurationTests.cs new file mode 100644 index 000000000..3173dfea3 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/Configuration/AuthorizationConfigurationTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Configuration; + +public class AuthorizationConfigurationTests +{ + [Fact] + public void DefaultConfigurationRequiresAuthenticatedUser() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddAuthorization(); + _ = services.AddConformanceReport(); + var provider = services.BuildServiceProvider(); + + var authOptions = provider.GetRequiredService>().Value; + var conformanceOptions = provider.GetRequiredService>().Value; + + // Act + var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName); + + // Assert + _ = policy.ShouldNotBeNull(); + policy.Requirements.ShouldContain(r => r is DenyAnonymousAuthorizationRequirement); + } + + [Fact] + public void CustomAuthorizationIsApplied() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddAuthorization(); + _ = services.AddConformanceReport(options => + { + options.ConfigureAuthorization = policy => + policy.RequireRole("TestRole"); + }); + var provider = services.BuildServiceProvider(); + + var authOptions = provider.GetRequiredService>().Value; + var conformanceOptions = provider.GetRequiredService>().Value; + + // Act + var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName); + + // Assert + _ = policy.ShouldNotBeNull(); + var roleRequirement = policy.Requirements.OfType().SingleOrDefault(); + _ = roleRequirement.ShouldNotBeNull(); + roleRequirement.AllowedRoles.ShouldContain("TestRole"); + } + + [Fact] + public void NullConfigurationDoesNotRegisterPolicy() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddAuthorization(); + _ = services.AddConformanceReport(options => + { + options.ConfigureAuthorization = null; + }); + var provider = services.BuildServiceProvider(); + + var authOptions = provider.GetRequiredService>().Value; + var conformanceOptions = provider.GetRequiredService>().Value; + + // Act + var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName); + + // Assert + policy.ShouldBeNull(); + } + + [Fact] + public void MultipleRequirementsCanBeConfigured() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddAuthorization(); + _ = services.AddConformanceReport(options => + { + options.ConfigureAuthorization = policy => + { + _ = policy.RequireRole("Admin"); + _ = policy.RequireClaim("department", "IT"); + }; + }); + var provider = services.BuildServiceProvider(); + + var authOptions = provider.GetRequiredService>().Value; + var conformanceOptions = provider.GetRequiredService>().Value; + + // Act + var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName); + + // Assert + _ = policy.ShouldNotBeNull(); + var roleRequirement = policy.Requirements.OfType().SingleOrDefault(); + _ = roleRequirement.ShouldNotBeNull(); + roleRequirement.AllowedRoles.ShouldContain("Admin"); + + var claimRequirement = policy.Requirements.OfType().SingleOrDefault(); + _ = claimRequirement.ShouldNotBeNull(); + claimRequirement.ClaimType.ShouldBe("department"); + _ = claimRequirement.AllowedValues.ShouldNotBeNull(); + claimRequirement.AllowedValues.ShouldContain("IT"); + } + + [Fact] + public void EmptyConfigurationAllowsAnonymous() + { + // Arrange + var services = new ServiceCollection(); + _ = services.AddAuthorization(); + _ = services.AddConformanceReport(options => + { + // Allow anonymous with assertion that always passes + options.ConfigureAuthorization = policy => policy.RequireAssertion(_ => true); + }); + var provider = services.BuildServiceProvider(); + + var authOptions = provider.GetRequiredService>().Value; + var conformanceOptions = provider.GetRequiredService>().Value; + + // Act + var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName); + + // Assert + _ = policy.ShouldNotBeNull(); + var assertionRequirement = policy.Requirements.OfType().SingleOrDefault(); + _ = assertionRequirement.ShouldNotBeNull(); + } +} diff --git a/conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj b/conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj new file mode 100644 index 000000000..3eaf368e1 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj @@ -0,0 +1,15 @@ + + + + ConformanceReport.Tests + + + + + + + + + + + diff --git a/conformance-report/test/ConformanceReport.Tests/Endpoints/ConformanceEndpointTests.cs b/conformance-report/test/ConformanceReport.Tests/Endpoints/ConformanceEndpointTests.cs new file mode 100644 index 000000000..1b6aaf762 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/Endpoints/ConformanceEndpointTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.ConformanceReport.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Endpoints; + +public class ConformanceReportEndpointTests +{ + private static ConformanceReportServerOptions CreateDefaultServerOptions() => + new() + { + PushedAuthorizationEndpointEnabled = true, + PushedAuthorizationRequired = true, + PushedAuthorizationLifetime = 600, + MutualTlsEnabled = true, + SupportedSigningAlgorithms = ["PS256", "ES256"], + JwtValidationClockSkew = TimeSpan.FromMinutes(5), + EmitIssuerIdentificationResponseParameter = true, + UseHttp303Redirects = true + }; + + private static ConformanceReportClient CreateTestClient(string clientId = "test-client") => + new() + { + ClientId = clientId, + ClientName = "Test Client", + AllowedGrantTypes = [ConformanceReportGrantTypes.AuthorizationCode], + RequirePkce = true, + AllowPlainTextPkce = false, + RedirectUris = ["https://example.com/callback"], + RequireClientSecret = true, + ClientSecretTypes = [ConformanceReportSecretTypes.JsonWebKey], + RequirePushedAuthorization = true, + RequireDPoP = true, + DPoPValidationMode = ConformanceReportDPoPValidationMode.Nonce, + AuthorizationCodeLifetime = 60, + AllowOfflineAccess = true, + RefreshTokenUsage = ConformanceReportTokenUsage.OneTimeOnly, + AllowAccessTokensViaBrowser = false, + RequireRequestObject = false + }; + + private static ConformanceReportOptions CreateDefaultOptions(bool enabled = true) => + new() + { + Enabled = enabled, + EnableOAuth21Assessment = true, + EnableFapi2SecurityAssessment = true, + PathPrefix = "__duende", + AuthorizationPolicyName = "conformance.report", + ConfigureAuthorization = null // Skip policy registration in tests + }; + + private static ConformanceReportEndpoint CreateEndpoint( + IConformanceReportClientStore? clientStore = null, + ConformanceReportOptions? options = null, + ConformanceReportLicenseInfo? licenseInfo = null) + { + options ??= CreateDefaultOptions(); + clientStore ??= new InMemoryClientStore([CreateTestClient()]); + + var serverOptions = CreateDefaultServerOptions(); + var httpContextAccessor = new TestHttpContextAccessor(); + + var assessmentService = new ConformanceReportAssessmentService( + Options.Create(options), + () => serverOptions, + clientStore, + httpContextAccessor, + licenseInfo); + + var endpoint = new ConformanceReportEndpoint( + assessmentService, + Options.Create(options), + NullLogger.Instance); + + return endpoint; + } + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/_duende/conformance/v1" + } + }; + return context; + } + + private sealed class InMemoryClientStore(IEnumerable clients) : IConformanceReportClientStore + { + public Task> GetAllClientsAsync(CancellationToken ct = default) + => Task.FromResult(clients); + } + + private sealed class TestHttpContextAccessor : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = CreateHttpContext(); + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext + { + Request = + { + Scheme = "https", + Host = new HostString("localhost"), + Path = "/__duende/conformance/v1" + } + }; + return context; + } + } + + public class HtmlEndpointTests + { + [Fact] + public async Task GetHtmlReportWhenEnabledReturnsHtmlContent() + { + var endpoint = CreateEndpoint(); + var context = CreateHttpContext(); + + var result = await endpoint.GetHtmlReportAsync(context); + + _ = result.ShouldNotBeNull(); + _ = result.ShouldBeOfType(); + var contentResult = (Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult)result; + contentResult.ContentType.ShouldBe("text/html"); + } + + [Fact] + public async Task GetHtmlReportWhenDisabledReturnsNotFound() + { + var options = CreateDefaultOptions(enabled: false); + var endpoint = CreateEndpoint(options: options); + var context = CreateHttpContext(); + + var result = await endpoint.GetHtmlReportAsync(context); + + _ = result.ShouldBeOfType(); + } + + [Fact] + public async Task GetHtmlReportWithLicenseDoesNotBleedIntoUrl() + { + var licenseInfo = new ConformanceReportLicenseInfo + { + CompanyName = "Test Company", + Edition = "Enterprise", + SerialNumber = 1234, + Expiration = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc) + }; + var endpoint = CreateEndpoint(licenseInfo: licenseInfo); + var context = CreateHttpContext(); + + var result = await endpoint.GetHtmlReportAsync(context); + + var contentResult = (Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult)result; + var html = contentResult.ResponseContent!; + + // The license info should be present + html.ShouldContain("Test Company | Enterprise | #1234 | Expires 2025-12-31"); + // The URL should NOT contain the expiration date (the bug!) + html.ShouldNotContain("conformance-report2025-12-31"); + } + } +} diff --git a/conformance-report/test/ConformanceReport.Tests/Services/ConformanceAssessmentServiceTests.cs b/conformance-report/test/ConformanceReport.Tests/Services/ConformanceAssessmentServiceTests.cs new file mode 100644 index 000000000..be744dd56 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/Services/ConformanceAssessmentServiceTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Duende.ConformanceReport.Services; + +public class ConformanceAssessmentServiceTests +{ + private static ConformanceReportServerOptions CreateDefaultServerOptions( + bool parEnabled = true, + bool parRequired = true, + bool mtlsEnabled = true, + IReadOnlyCollection? signingAlgorithms = null, + bool emitIssuer = true, + bool useHttp303Redirects = true) => + new() + { + PushedAuthorizationEndpointEnabled = parEnabled, + PushedAuthorizationRequired = parRequired, + PushedAuthorizationLifetime = 600, + MutualTlsEnabled = mtlsEnabled, + SupportedSigningAlgorithms = signingAlgorithms ?? ["PS256", "ES256"], + JwtValidationClockSkew = TimeSpan.FromMinutes(5), + EmitIssuerIdentificationResponseParameter = emitIssuer, + UseHttp303Redirects = useHttp303Redirects + }; + + private static ConformanceReportClient CreateCompliantClient(string clientId = "compliant-client") => + new() + { + ClientId = clientId, + ClientName = "Compliant Client", + AllowedGrantTypes = [ConformanceReportGrantTypes.AuthorizationCode], + RequirePkce = true, + AllowPlainTextPkce = false, + RedirectUris = ["https://example.com/callback"], + RequireClientSecret = true, + ClientSecretTypes = [ConformanceReportSecretTypes.JsonWebKey], + RequirePushedAuthorization = true, + RequireDPoP = true, + DPoPValidationMode = ConformanceReportDPoPValidationMode.Nonce, + AuthorizationCodeLifetime = 60, + AllowOfflineAccess = true, + RefreshTokenUsage = ConformanceReportTokenUsage.OneTimeOnly, + AllowAccessTokensViaBrowser = false, + RequireRequestObject = false + }; + + private static ConformanceReportClient CreateNonCompliantClient(string clientId = "non-compliant-client") => + new() + { + ClientId = clientId, + ClientName = "Non-Compliant Client", + AllowedGrantTypes = [ConformanceReportGrantTypes.Implicit], // Not allowed + RequirePkce = false, // Required + AllowPlainTextPkce = true, // Should be false + RedirectUris = ["https://*.example.com/callback"], // Wildcard + RequireClientSecret = false, // For FAPI should be true + ClientSecretTypes = [ConformanceReportSecretTypes.SharedSecret], + RequirePushedAuthorization = false, + RequireDPoP = false, + DPoPValidationMode = ConformanceReportDPoPValidationMode.None, + AuthorizationCodeLifetime = 300, // Too long + AllowOfflineAccess = false, + RefreshTokenUsage = ConformanceReportTokenUsage.ReUse, + AllowAccessTokensViaBrowser = true, // Not allowed + RequireRequestObject = false + }; + + private static ConformanceReportOptions CreateDefaultOptions( + bool enableOAuth21 = true, + bool enableFapi2 = true) => + new() + { + Enabled = true, + EnableOAuth21Assessment = enableOAuth21, + EnableFapi2SecurityAssessment = enableFapi2, + PathPrefix = "__duende", + AuthorizationPolicyName = "conformance.report", + ConfigureAuthorization = null // Skip policy registration in tests + }; + + private static ConformanceReportAssessmentService CreateService( + ConformanceReportOptions? options = null, + ConformanceReportServerOptions? serverOptions = null, + IEnumerable? clients = null) + { + options ??= CreateDefaultOptions(); + serverOptions ??= CreateDefaultServerOptions(); + clients ??= [CreateCompliantClient()]; + + var clientStore = new InMemoryClientStore(clients); + var httpContextAccessor = new TestHttpContextAccessor(); + + return new ConformanceReportAssessmentService( + Options.Create(options), + () => serverOptions, + clientStore, + httpContextAccessor); + } + + private sealed class InMemoryClientStore(IEnumerable clients) : IConformanceReportClientStore + { + public Task> GetAllClientsAsync(CancellationToken ct = default) => Task.FromResult(clients); + } + + private sealed class TestHttpContextAccessor : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = CreateHttpContext(); + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "https"; + context.Request.Host = new HostString("localhost"); + context.Request.Path = "/__duende/conformance/v1"; + return context; + } + } + + public class ReportGenerationTests + { + [Fact] + public async Task GenerateReportWithBothProfilesEnabledReturnsCompleteReport() + { + var service = CreateService(); + + var report = await service.GenerateReportAsync(); + + _ = report.ShouldNotBeNull(); + _ = report.Profiles.ShouldNotBeNull(); + _ = report.Profiles.OAuth21.ShouldNotBeNull(); + _ = report.Profiles.Fapi2Security.ShouldNotBeNull(); + } + + [Fact] + public async Task GenerateReportWithOnlyOAuth21EnabledReturnsOAuth21Only() + { + var options = CreateDefaultOptions(enableOAuth21: true, enableFapi2: false); + var service = CreateService(options: options); + + var report = await service.GenerateReportAsync(); + + _ = report.Profiles.OAuth21.ShouldNotBeNull(); + report.Profiles.Fapi2Security.ShouldBeNull(); + } + + [Fact] + public async Task GenerateReportWithOnlyFapi2EnabledReturnsFapi2Only() + { + var options = CreateDefaultOptions(enableOAuth21: false, enableFapi2: true); + var service = CreateService(options: options); + + var report = await service.GenerateReportAsync(); + + report.Profiles.OAuth21.ShouldBeNull(); + _ = report.Profiles.Fapi2Security.ShouldNotBeNull(); + } + + [Fact] + public async Task GenerateReportSetsAssessedAtTimestamp() + { + var service = CreateService(); + var beforeTime = DateTimeOffset.UtcNow; + + var report = await service.GenerateReportAsync(); + + var afterTime = DateTimeOffset.UtcNow; + report.AssessedAt.ShouldBeGreaterThanOrEqualTo(beforeTime); + report.AssessedAt.ShouldBeLessThanOrEqualTo(afterTime); + } + + [Fact] + public async Task GenerateReportWithMixedClientsCalculatesCorrectSummary() + { + var clients = new[] + { + CreateCompliantClient("pass1"), + CreateCompliantClient("pass2"), + CreateNonCompliantClient("fail1") + }; + var service = CreateService(clients: clients); + + var report = await service.GenerateReportAsync(); + + // Overall summary + report.OverallSummary.TotalClients.ShouldBe(3); + report.Status.ShouldBe(ConformanceReportStatus.Fail); + + // OAuth 2.1 summary + var oauth21Summary = report.Profiles.OAuth21!.Summary; + oauth21Summary.TotalClients.ShouldBe(3); + oauth21Summary.PassingClients.ShouldBe(2); + oauth21Summary.FailingClients.ShouldBe(1); + } + } +} diff --git a/conformance-report/test/ConformanceReport.Tests/Services/Fapi2SecurityAssessorTests.cs b/conformance-report/test/ConformanceReport.Tests/Services/Fapi2SecurityAssessorTests.cs new file mode 100644 index 000000000..12c910809 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/Services/Fapi2SecurityAssessorTests.cs @@ -0,0 +1,891 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Models; + +namespace Duende.ConformanceReport.Services; + +public class Fapi2SecurityAssessorTests +{ + private static ConformanceReportServerOptions CreateDefaultServerOptions( + bool parEnabled = true, + bool parRequired = true, + int parLifetime = 600, + bool mtlsEnabled = true, + IReadOnlyCollection? signingAlgorithms = null, + TimeSpan? clockSkew = null, + bool emitIssuer = true, + bool useHttp303Redirects = true) => + new() + { + PushedAuthorizationEndpointEnabled = parEnabled, + PushedAuthorizationRequired = parRequired, + PushedAuthorizationLifetime = parLifetime, + MutualTlsEnabled = mtlsEnabled, + SupportedSigningAlgorithms = signingAlgorithms ?? ["PS256", "ES256"], + JwtValidationClockSkew = clockSkew ?? TimeSpan.FromMinutes(5), + EmitIssuerIdentificationResponseParameter = emitIssuer, + UseHttp303Redirects = useHttp303Redirects + }; + + private static ConformanceReportClient CreateFapi2CompliantClient( + string clientId = "fapi-client", + IReadOnlyCollection? grantTypes = null, + bool requirePkce = true, + bool allowPlainTextPkce = false, + IReadOnlyCollection? redirectUris = null, + bool requireClientSecret = true, + IReadOnlyCollection? secretTypes = null, + bool requirePar = true, + bool requireDPoP = true, + ConformanceReportDPoPValidationMode dpopMode = ConformanceReportDPoPValidationMode.Nonce, + int authCodeLifetime = 60, + bool allowOfflineAccess = true, + ConformanceReportTokenUsage refreshTokenUsage = ConformanceReportTokenUsage.OneTimeOnly, + bool allowAccessTokensViaBrowser = false, + bool requireRequestObject = false) => + new() + { + ClientId = clientId, + ClientName = "FAPI 2.0 Client", + AllowedGrantTypes = grantTypes ?? [ConformanceReportGrantTypes.AuthorizationCode], + RequirePkce = requirePkce, + AllowPlainTextPkce = allowPlainTextPkce, + RedirectUris = redirectUris ?? ["https://example.com/callback"], + RequireClientSecret = requireClientSecret, + ClientSecretTypes = secretTypes ?? [ConformanceReportSecretTypes.JsonWebKey], + RequirePushedAuthorization = requirePar, + RequireDPoP = requireDPoP, + DPoPValidationMode = dpopMode, + AuthorizationCodeLifetime = authCodeLifetime, + AllowOfflineAccess = allowOfflineAccess, + RefreshTokenUsage = refreshTokenUsage, + AllowAccessTokensViaBrowser = allowAccessTokensViaBrowser, + RequireRequestObject = requireRequestObject + }; + + private static Finding GetFinding(IReadOnlyList findings, string ruleId) => findings.First(f => f.RuleId == ruleId); + + public class ServerAssessments + { + [Fact] + public void FS01PAREnabledAndRequiredPasses() + { + var options = CreateDefaultServerOptions(parEnabled: true, parRequired: true); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS01"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("required globally"); + } + + [Fact] + public void FS01PAREnabledNotRequiredWarns() + { + var options = CreateDefaultServerOptions(parEnabled: true, parRequired: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS01"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("not required globally"); + _ = finding.Recommendation.ShouldNotBeNull(); + } + + [Fact] + public void FS01PARDisabledFails() + { + var options = CreateDefaultServerOptions(parEnabled: false, parRequired: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS01"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("not enabled"); + } + + [Fact] + public void FS02MTLSEnabledPasses() + { + var options = CreateDefaultServerOptions(mtlsEnabled: true); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS02"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS is enabled"); + } + + [Fact] + public void FS02MTLSDisabledWarns() + { + var options = CreateDefaultServerOptions(mtlsEnabled: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS02"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("mTLS is not enabled"); + } + + [Theory] + [InlineData("PS256")] + [InlineData("ES256")] + [InlineData("PS256,ES256")] + [InlineData("PS384,PS512")] + [InlineData("ES384,ES512")] + [InlineData("PS256,PS384,PS512,ES256,ES384,ES512")] + public void FS03FAPICompliantAlgorithmsPasses(string algorithmsCommaSeparated) + { + var algorithms = algorithmsCommaSeparated.Split(','); + var options = CreateDefaultServerOptions(signingAlgorithms: algorithms); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS03"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FS03RS256MixedWithFAPIWarns() + { + var options = CreateDefaultServerOptions(signingAlgorithms: ["PS256", "RS256"]); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS03"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("RS256"); + } + + [Fact] + public void FS03OnlyNonFAPIAlgorithmsFails() + { + var options = CreateDefaultServerOptions(signingAlgorithms: ["RS256", "HS256"]); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS03"); + finding.Status.ShouldBe(FindingStatus.Fail); + } + + [Theory] + [InlineData(60)] + [InlineData(300)] + [InlineData(600)] + public void FS04PARLifetimeWithinRangePasses(int lifetime) + { + var options = CreateDefaultServerOptions(parLifetime: lifetime); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS04"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Theory] + [InlineData(601)] + [InlineData(900)] + public void FS04PARLifetimeExceedsRangeFails(int lifetime) + { + var options = CreateDefaultServerOptions(parLifetime: lifetime); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS04"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Recommendation!.ShouldContain("600"); + } + + [Fact] + public void FS05MTLSEnabledPasses() + { + var options = CreateDefaultServerOptions(mtlsEnabled: true); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS is enabled"); + } + + [Fact] + public void FS05MTLSDisabledStillPassesDPoPAvailable() + { + var options = CreateDefaultServerOptions(mtlsEnabled: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("DPoP is available"); + } + + [Fact] + public void FS06IssuerIdentificationEnabledPasses() + { + var options = CreateDefaultServerOptions(emitIssuer: true); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS06"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("enabled"); + } + + [Fact] + public void FS06IssuerIdentificationDisabledFails() + { + var options = CreateDefaultServerOptions(emitIssuer: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS06"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("mix-up attack"); + _ = finding.Recommendation.ShouldNotBeNull(); + } + + [Fact] + public void FS07Http303RedirectsEnabledPasses() + { + var options = CreateDefaultServerOptions(useHttp303Redirects: true); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS07"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("303"); + } + + [Fact] + public void FS07Http303RedirectsDisabledFails() + { + var options = CreateDefaultServerOptions(useHttp303Redirects: false); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS07"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("Section 5.3.2.2"); + } + + [Fact] + public void FS08PKCESupportPasses() + { + var options = CreateDefaultServerOptions(); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS08"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + } + + public class ClientAssessments + { + private readonly Fapi2SecurityAssessor _assessor = new(CreateDefaultServerOptions()); + + [Theory] + [InlineData("AuthorizationCode", FindingStatus.Pass)] + [InlineData("ClientCredentials", FindingStatus.Pass)] + [InlineData("Implicit", FindingStatus.Fail)] + [InlineData("Password", FindingStatus.Fail)] + [InlineData("DeviceCode", FindingStatus.Fail)] + public void FC01GrantTypeValidation(string grantType, FindingStatus expectedStatus) + { + var grantTypes = grantType switch + { + "AuthorizationCode" => new[] { ConformanceReportGrantTypes.AuthorizationCode }, + "ClientCredentials" => new[] { ConformanceReportGrantTypes.ClientCredentials }, + "Implicit" => new[] { ConformanceReportGrantTypes.Implicit }, + "Password" => new[] { ConformanceReportGrantTypes.Password }, + "DeviceCode" => new[] { ConformanceReportGrantTypes.DeviceCode }, + _ => throw new ArgumentException($"Unknown grant type: {grantType}") + }; + + var client = CreateFapi2CompliantClient(grantTypes: grantTypes); + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC01"); + finding.Status.ShouldBe(expectedStatus); + if (expectedStatus == FindingStatus.Fail && grantType == "Implicit") + { + finding.Message.ShouldContain("implicit"); + } + } + + [Fact] + public void FC02ConfidentialClientPasses() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requireClientSecret: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC02"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC02PublicClientFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requireClientSecret: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC02"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("public"); + } + + [Theory] + [InlineData("FC02")] + [InlineData("FC03")] + [InlineData("FC07")] + [InlineData("FC10")] + [InlineData("FC11")] + [InlineData("FC12")] + public void RuleNotApplicableForClientCredentials(string ruleId) + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.ClientCredentials]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, ruleId); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Fact] + public void FC03PKCES256Passes() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: true, + allowPlainTextPkce: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC03"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC03PKCENotRequiredFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC03"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("PKCE is not required"); + } + + [Fact] + public void FC03PlainTextPKCEFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: true, + allowPlainTextPkce: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC03"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("Plain text PKCE"); + } + + + + [Fact] + public void FC04PARRequiredClientPasses() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC04"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC04PARNotRequiredFails() + { + var options = CreateDefaultServerOptions(parRequired: false); + var assessor = new Fapi2SecurityAssessor(options); + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false); + + var findings = assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC04"); + finding.Status.ShouldBe(FindingStatus.Fail); + } + + [Fact] + public void FC04PARRequiredServerWidePasses() + { + var options = CreateDefaultServerOptions(parRequired: true); + var assessor = new Fapi2SecurityAssessor(options); + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false); + + var findings = assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC04"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC05DPoPRequiredPasses() + { + var client = CreateFapi2CompliantClient(requireDPoP: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("DPoP"); + } + + [Fact] + public void FC05MTLSPasses() + { + var client = CreateFapi2CompliantClient( + requireDPoP: false, + secretTypes: [ConformanceReportSecretTypes.X509CertificateThumbprint]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS"); + } + + [Fact] + public void FC05NoSenderConstraintFails() + { + var client = CreateFapi2CompliantClient( + requireDPoP: false, + secretTypes: [ConformanceReportSecretTypes.SharedSecret]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC05"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("FAPI 2.0 requires"); + } + + [Fact] + public void FC06PrivateKeyJWTPasses() + { + var client = CreateFapi2CompliantClient( + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.JsonWebKey]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC06"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("private_key_jwt"); + } + + [Fact] + public void FC06MTLSPasses() + { + var client = CreateFapi2CompliantClient( + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.X509CertificateThumbprint]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC06"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS"); + } + + [Fact] + public void FC06SharedSecretFails() + { + var client = CreateFapi2CompliantClient( + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.SharedSecret]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC06"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("shared secret"); + } + + [Fact] + public void FC06PublicClientFails() + { + var client = CreateFapi2CompliantClient(requireClientSecret: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC06"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("public"); + } + + [Theory] + [InlineData(30)] + [InlineData(60)] + public void FC07AuthCodeLifetimeWithinRangePasses(int seconds) + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + authCodeLifetime: seconds); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC07"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Theory] + [InlineData(61)] + [InlineData(120)] + public void FC07AuthCodeLifetimeExceedsRangeFails(int seconds) + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + authCodeLifetime: seconds); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC07"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Recommendation!.ShouldContain("60"); + } + + + + [Fact] + public void FC08RefreshTokenRotationEnabledPasses() + { + var client = CreateFapi2CompliantClient( + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.OneTimeOnly); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC08"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC08RefreshTokenRotationDisabledFails() + { + var client = CreateFapi2CompliantClient( + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.ReUse); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC08"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("reusable"); + } + + [Fact] + public void FC08NotApplicableNoOfflineAccess() + { + var client = CreateFapi2CompliantClient(allowOfflineAccess: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC08"); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Fact] + public void FC09DPoPNonceEnabledPasses() + { + var client = CreateFapi2CompliantClient( + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.Nonce); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC09"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC09DPoPNonceDisabledFails() + { + var client = CreateFapi2CompliantClient( + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.None); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC09"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("replay protection"); + } + + [Fact] + public void FC09DPoPNonceWithIatPasses() + { + var client = CreateFapi2CompliantClient( + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.Nonce | ConformanceReportDPoPValidationMode.Iat); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC09"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC09NotApplicableNoDPoP() + { + var client = CreateFapi2CompliantClient(requireDPoP: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC09"); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Fact] + public void FC10ExplicitRedirectUriPasses() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: ["https://example.com/callback"]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC10"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC10NoRedirectUrisFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: []); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC10"); + finding.Status.ShouldBe(FindingStatus.Fail); + } + + [Fact] + public void FC10WildcardRedirectUriFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: ["https://*.example.com/callback"]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC10"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("Wildcard"); + } + + + + [Fact] + public void FC11AccessTokensViaBrowserDisabledPasses() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowAccessTokensViaBrowser: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC11"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC11AccessTokensViaBrowserEnabledFails() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowAccessTokensViaBrowser: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC11"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("prohibits"); + } + + + + [Fact] + public void FC12RequestObjectRequiredPasses() + { + var options = CreateDefaultServerOptions(parRequired: false); + var assessor = new Fapi2SecurityAssessor(options); + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false, + requireRequestObject: true); + + var findings = assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC12"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void FC12PARRequiredPasses() + { + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: true, + requireRequestObject: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC12"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("PAR is required"); + } + + [Fact] + public void FC12NeitherRequestObjectNorPARWarns() + { + var options = CreateDefaultServerOptions(parRequired: false); + var assessor = new Fapi2SecurityAssessor(options); + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false, + requireRequestObject: false); + + var findings = assessor.AssessClient(client); + + var finding = GetFinding(findings, "FC12"); + finding.Status.ShouldBe(FindingStatus.Warning); + } + + + } + + public class CompleteConfigurationTests + { + [Fact] + public void FAPI2CompliantServerHasAllPasses() + { + var options = CreateDefaultServerOptions( + parEnabled: true, + parRequired: true, + parLifetime: 600, + mtlsEnabled: true, + signingAlgorithms: ["PS256", "ES256"], + emitIssuer: true, + useHttp303Redirects: true); + + var assessor = new Fapi2SecurityAssessor(options); + var findings = assessor.AssessServer(); + + findings.ShouldNotBeEmpty(); + findings.Count.ShouldBe(8); // FS01-FS08 + findings.ShouldNotContain(f => f.Status == FindingStatus.Fail); + } + + [Fact] + public void FAPI2CompliantClientHasAllPasses() + { + var options = CreateDefaultServerOptions(); + var assessor = new Fapi2SecurityAssessor(options); + + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode, ConformanceReportGrantTypes.RefreshToken], + requirePkce: true, + allowPlainTextPkce: false, + redirectUris: ["https://example.com/callback"], + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.JsonWebKey], + requirePar: true, + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.Nonce, + authCodeLifetime: 60, + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.OneTimeOnly, + allowAccessTokensViaBrowser: false, + requireRequestObject: false); + + var findings = assessor.AssessClient(client); + + findings.ShouldNotBeEmpty(); + findings.Count.ShouldBe(12); // FC01-FC12 + findings.ShouldNotContain(f => f.Status == FindingStatus.Fail); + } + + [Fact] + public void NonCompliantServerWithRS256HasFailure() + { + var options = CreateDefaultServerOptions(signingAlgorithms: ["RS256"]); + var assessor = new Fapi2SecurityAssessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "FS03"); + finding.Status.ShouldBe(FindingStatus.Fail); + } + + [Fact] + public void NonCompliantClientWithSharedSecretHasFailures() + { + var options = CreateDefaultServerOptions(); + var assessor = new Fapi2SecurityAssessor(options); + + var client = CreateFapi2CompliantClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.SharedSecret], + requireDPoP: false); + + var findings = assessor.AssessClient(client); + + // Should fail on FC05 (sender-constrained) and FC06 (client auth) + var fc05 = GetFinding(findings, "FC05"); + var fc06 = GetFinding(findings, "FC06"); + + fc05.Status.ShouldBe(FindingStatus.Fail); + fc06.Status.ShouldBe(FindingStatus.Fail); + } + } +} diff --git a/conformance-report/test/ConformanceReport.Tests/Services/OAuth21AssessorTests.cs b/conformance-report/test/ConformanceReport.Tests/Services/OAuth21AssessorTests.cs new file mode 100644 index 000000000..1d84c18e5 --- /dev/null +++ b/conformance-report/test/ConformanceReport.Tests/Services/OAuth21AssessorTests.cs @@ -0,0 +1,871 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport.Models; + +namespace Duende.ConformanceReport.Services; + +public class OAuth21AssessorTests +{ + private static ConformanceReportServerOptions CreateDefaultServerOptions( + bool parEnabled = true, + bool parRequired = false, + bool mtlsEnabled = false, + IReadOnlyCollection? signingAlgorithms = null, + TimeSpan? clockSkew = null, + bool useHttp303Redirects = true) => + new() + { + PushedAuthorizationEndpointEnabled = parEnabled, + PushedAuthorizationRequired = parRequired, + PushedAuthorizationLifetime = 600, + MutualTlsEnabled = mtlsEnabled, + SupportedSigningAlgorithms = signingAlgorithms ?? ["RS256", "ES256"], + JwtValidationClockSkew = clockSkew ?? TimeSpan.FromMinutes(5), + EmitIssuerIdentificationResponseParameter = true, + UseHttp303Redirects = useHttp303Redirects + }; + + private static ConformanceReportClient CreateDefaultClient( + string clientId = "test-client", + IReadOnlyCollection? grantTypes = null, + bool requirePkce = true, + bool allowPlainTextPkce = false, + IReadOnlyCollection? redirectUris = null, + bool requireClientSecret = true, + IReadOnlyCollection? secretTypes = null, + bool requirePar = false, + bool requireDPoP = false, + ConformanceReportDPoPValidationMode dpopMode = ConformanceReportDPoPValidationMode.None, + int authCodeLifetime = 60, + bool allowOfflineAccess = true, + ConformanceReportTokenUsage refreshTokenUsage = ConformanceReportTokenUsage.OneTimeOnly, + bool allowAccessTokensViaBrowser = false, + bool requireRequestObject = false) => + new() + { + ClientId = clientId, + ClientName = "Test Client", + AllowedGrantTypes = grantTypes ?? [ConformanceReportGrantTypes.AuthorizationCode], + RequirePkce = requirePkce, + AllowPlainTextPkce = allowPlainTextPkce, + RedirectUris = redirectUris ?? ["https://example.com/callback"], + RequireClientSecret = requireClientSecret, + ClientSecretTypes = secretTypes ?? [ConformanceReportSecretTypes.SharedSecret], + RequirePushedAuthorization = requirePar, + RequireDPoP = requireDPoP, + DPoPValidationMode = dpopMode, + AuthorizationCodeLifetime = authCodeLifetime, + AllowOfflineAccess = allowOfflineAccess, + RefreshTokenUsage = refreshTokenUsage, + AllowAccessTokensViaBrowser = allowAccessTokensViaBrowser, + RequireRequestObject = requireRequestObject + }; + + private static Finding GetFinding(IReadOnlyList findings, string ruleId) + => findings.First(f => f.RuleId == ruleId); + + public class ServerAssessments + { + [Fact] + public void S01PKCESupportAlwaysPasses() + { + // Server always supports PKCE; client config is assessed separately + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S01"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.RuleName.ShouldBe("PKCE Support"); + } + + [Fact] + public void S02PasswordGrantProhibitionAlwaysPasses() + { + // Password grant is assessed at client level, so server level always passes + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S02"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.RuleName.ShouldBe("Resource Owner Password Grant Prohibition"); + } + + [Fact] + public void S03PAREnabledPasses() + { + var options = CreateDefaultServerOptions(parEnabled: true); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S03"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("PAR endpoint is enabled"); + } + + [Fact] + public void S03PARDisabledWarns() + { + var options = CreateDefaultServerOptions(parEnabled: false); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S03"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("not enabled"); + _ = finding.Recommendation.ShouldNotBeNull(); + } + + [Fact] + public void S04SenderConstrainedMTLSEnabledPasses() + { + var options = CreateDefaultServerOptions(mtlsEnabled: true); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S04"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS"); + } + + [Fact] + public void S04SenderConstrainedDPoPAlwaysSupported() + { + // DPoP is always supported when configured per-client + var options = CreateDefaultServerOptions(mtlsEnabled: false); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S04"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("DPoP"); + } + + [Fact] + public void S05SecureSigningAlgorithmsPasses() + { + var options = CreateDefaultServerOptions(signingAlgorithms: ["RS256", "ES256", "PS256"]); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("secure"); + } + + [Theory] + [InlineData("HS256")] + [InlineData("HS384")] + [InlineData("HS512")] + [InlineData("none")] + public void S05InsecureSigningAlgorithmsFails(string algorithm) + { + var options = CreateDefaultServerOptions(signingAlgorithms: ["RS256", algorithm]); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S05"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain(algorithm); + _ = finding.Recommendation.ShouldNotBeNull(); + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + public void S06ClockSkewWithinRangePasses(int minutes) + { + var options = CreateDefaultServerOptions(clockSkew: TimeSpan.FromMinutes(minutes)); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S06"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Theory] + [InlineData(6)] + [InlineData(10)] + public void S06ClockSkewExceedsRangeWarns(int minutes) + { + var options = CreateDefaultServerOptions(clockSkew: TimeSpan.FromMinutes(minutes)); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S06"); + finding.Status.ShouldBe(FindingStatus.Warning); + _ = finding.Recommendation.ShouldNotBeNull(); + } + + [Fact] + public void S07DPoPNonceSupportPasses() + { + // DPoP nonce is configured per-client, server just notes support + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S07"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void S08Http303RedirectsEnabledPasses() + { + var options = CreateDefaultServerOptions(useHttp303Redirects: true); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S08"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("303"); + } + + [Fact] + public void S08Http303RedirectsDisabledWarns() + { + var options = CreateDefaultServerOptions(useHttp303Redirects: false); + var assessor = new OAuth21Assessor(options); + + var findings = assessor.AssessServer(); + + var finding = GetFinding(findings, "S08"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("302"); + _ = finding.Recommendation.ShouldNotBeNull(); + } + } + + public class ClientAssessments + { + private readonly OAuth21Assessor _assessor = new(CreateDefaultServerOptions()); + + [Theory] + [InlineData("AuthorizationCode", FindingStatus.Pass)] + [InlineData("ClientCredentials", FindingStatus.Pass)] + [InlineData("RefreshToken", FindingStatus.Pass)] + [InlineData("Implicit", FindingStatus.Fail)] + [InlineData("Password", FindingStatus.Fail)] + public void C01GrantTypeValidation(string grantType, FindingStatus expectedStatus) + { + var grantTypes = grantType switch + { + "AuthorizationCode" => new[] { ConformanceReportGrantTypes.AuthorizationCode }, + "ClientCredentials" => new[] { ConformanceReportGrantTypes.ClientCredentials }, + "RefreshToken" => new[] { ConformanceReportGrantTypes.AuthorizationCode, ConformanceReportGrantTypes.RefreshToken }, + "Implicit" => new[] { ConformanceReportGrantTypes.Implicit }, + "Password" => new[] { ConformanceReportGrantTypes.Password }, + _ => throw new ArgumentException($"Unknown grant type: {grantType}") + }; + + var client = CreateDefaultClient(grantTypes: grantTypes); + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C01"); + finding.Status.ShouldBe(expectedStatus); + + if (expectedStatus == FindingStatus.Fail) + { + if (grantType == "Implicit") + { + finding.Message.ShouldContain("implicit"); + finding.Recommendation!.ShouldContain("Remove implicit"); + } + else if (grantType == "Password") + { + finding.Message.ShouldContain("password"); + } + } + } + + [Fact] + public void C02PKCERequiredForAuthCodePasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C02"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void C02PKCENotRequiredForAuthCodeFails() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C02"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("OAuth 2.1 mandates PKCE"); + finding.Recommendation!.ShouldContain("RequirePkce = true"); + } + + [Theory] + [InlineData("C02")] + [InlineData("C03")] + [InlineData("C04")] + [InlineData("C06")] + [InlineData("C08")] + [InlineData("C12")] + public void RuleNotApplicableForClientCredentials(string ruleId) + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.ClientCredentials]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, ruleId); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Fact] + public void C03PlainTextPkceDisabledPasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowPlainTextPkce: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C03"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("S256"); + } + + [Fact] + public void C03PlainTextPkceEnabledFails() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowPlainTextPkce: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C03"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("Plain text PKCE is allowed"); + finding.Recommendation!.ShouldContain("AllowPlainTextPkce = false"); + } + + + + [Fact] + public void C04ExplicitRedirectUriPasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: ["https://example.com/callback"]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C04"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void C04MultipleExplicitRedirectUrisPasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: ["https://example.com/callback", "https://example.com/signin"]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C04"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("2 redirect URI(s)"); + } + + [Fact] + public void C04NoRedirectUrisFails() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: []); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C04"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("No redirect URIs"); + } + + [Fact] + public void C04WildcardRedirectUriFails() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + redirectUris: ["https://*.example.com/callback"]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C04"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("Wildcard"); + finding.Recommendation!.ShouldContain("explicit"); + } + + + + [Fact] + public void C05ConfidentialClientWithSecretPasses() + { + var client = CreateDefaultClient( + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.SharedSecret]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("Confidential client has secrets"); + } + + [Fact] + public void C05ConfidentialClientNoSecretsFails() + { + var client = CreateDefaultClient( + requireClientSecret: true, + secretTypes: []); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C05"); + finding.Status.ShouldBe(FindingStatus.Fail); + finding.Message.ShouldContain("no secrets configured"); + } + + [Fact] + public void C05PublicClientWithPkcePasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requireClientSecret: false, + requirePkce: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C05"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("Public client using authorization_code with PKCE"); + } + + [Fact] + public void C05PublicClientWithoutPkceWarns() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requireClientSecret: false, + requirePkce: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C05"); + finding.Status.ShouldBe(FindingStatus.Warning); + } + + [Fact] + public void C06PARRequiredPasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C06"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void C06PARNotRequiredWarns() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C06"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Recommendation!.ShouldContain("RequirePushedAuthorization = true"); + } + + + + [Fact] + public void C06PARRequiredServerWidePasses() + { + var options = CreateDefaultServerOptions(parRequired: true); + var assessor = new OAuth21Assessor(options); + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePar: false); + + var findings = assessor.AssessClient(client); + + var finding = GetFinding(findings, "C06"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void C07DPoPRequiredPasses() + { + var client = CreateDefaultClient(requireDPoP: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C07"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("DPoP"); + } + + [Fact] + public void C07MTLSCertificatePasses() + { + var client = CreateDefaultClient( + secretTypes: [ConformanceReportSecretTypes.X509CertificateThumbprint]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C07"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("mTLS"); + } + + [Fact] + public void C07DPoPAndMTLSPasses() + { + var client = CreateDefaultClient( + requireDPoP: true, + secretTypes: [ConformanceReportSecretTypes.X509CertificateThumbprint]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C07"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("DPoP"); + finding.Message.ShouldContain("mTLS"); + } + + [Fact] + public void C07NoSenderConstraintWarns() + { + var client = CreateDefaultClient( + requireDPoP: false, + secretTypes: [ConformanceReportSecretTypes.SharedSecret]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C07"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Recommendation!.ShouldContain("DPoP"); + } + + [Theory] + [InlineData(30)] + [InlineData(60)] + public void C08AuthCodeLifetimeWithinRangePasses(int seconds) + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + authCodeLifetime: seconds); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C08"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Theory] + [InlineData(120)] + [InlineData(300)] + public void C08AuthCodeLifetimeTooLongWarns(int seconds) + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + authCodeLifetime: seconds); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C08"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Recommendation!.ShouldContain("reducing AuthorizationCodeLifetime"); + } + + + + [Fact] + public void C09RefreshTokenRotationEnabledPasses() + { + var client = CreateDefaultClient( + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.OneTimeOnly); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C09"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("one-time use"); + } + + [Fact] + public void C09RefreshTokenRotationDisabledWarns() + { + var client = CreateDefaultClient( + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.ReUse); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C09"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("reusable"); + finding.Recommendation!.ShouldContain("RefreshTokenUsage"); + } + + [Fact] + public void C09RefreshTokenRotationNotApplicableNoOfflineAccess() + { + var client = CreateDefaultClient(allowOfflineAccess: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C09"); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Fact] + public void C10DPoPNonceEnabledPasses() + { + var client = CreateDefaultClient( + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.Nonce); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C10"); + finding.Status.ShouldBe(FindingStatus.Pass); + finding.Message.ShouldContain("nonce validation is enabled"); + } + + [Fact] + public void C10DPoPNonceDisabledWarns() + { + var client = CreateDefaultClient( + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.None); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C10"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("not enabled"); + } + + [Fact] + public void C10DPoPNotApplicableWhenNotRequired() + { + var client = CreateDefaultClient(requireDPoP: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C10"); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + } + + [Theory] + [InlineData("JsonWebKey", "private_key_jwt")] + [InlineData("X509CertificateThumbprint", "mTLS")] + [InlineData("X509CertificateName", null)] + public void C11SecureSecretTypesPasses(string secretType, string? expectedMessageSubstring) + { + var secretTypeValue = secretType switch + { + "JsonWebKey" => ConformanceReportSecretTypes.JsonWebKey, + "X509CertificateThumbprint" => ConformanceReportSecretTypes.X509CertificateThumbprint, + "X509CertificateName" => ConformanceReportSecretTypes.X509CertificateName, + _ => throw new ArgumentException($"Unknown secret type: {secretType}") + }; + + var client = CreateDefaultClient( + requireClientSecret: true, + secretTypes: [secretTypeValue]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C11"); + finding.Status.ShouldBe(FindingStatus.Pass); + + if (expectedMessageSubstring != null) + { + finding.Message.ShouldContain(expectedMessageSubstring); + } + } + + [Fact] + public void C11SharedSecretWarns() + { + var client = CreateDefaultClient( + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.SharedSecret]); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C11"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Message.ShouldContain("shared secret"); + finding.Recommendation!.ShouldContain("private_key_jwt or mTLS"); + } + + [Fact] + public void C11PublicClientNotApplicable() + { + var client = CreateDefaultClient(requireClientSecret: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C11"); + finding.Status.ShouldBe(FindingStatus.NotApplicable); + finding.Message.ShouldContain("public client"); + } + + [Fact] + public void C12RefreshTokensEnabledPasses() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowOfflineAccess: true); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C12"); + finding.Status.ShouldBe(FindingStatus.Pass); + } + + [Fact] + public void C12RefreshTokensDisabledWarns() + { + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + allowOfflineAccess: false); + + var findings = _assessor.AssessClient(client); + + var finding = GetFinding(findings, "C12"); + finding.Status.ShouldBe(FindingStatus.Warning); + finding.Recommendation!.ShouldContain("AllowOfflineAccess = true"); + } + + + } + + public class CompleteConfigurationTests + { + [Fact] + public void OAuth21CompliantServerHasAllPassesOrWarnings() + { + var options = CreateDefaultServerOptions( + parEnabled: true, + mtlsEnabled: true, + signingAlgorithms: ["ES256", "PS256"], + clockSkew: TimeSpan.FromMinutes(2), + useHttp303Redirects: true); + + var assessor = new OAuth21Assessor(options); + var findings = assessor.AssessServer(); + + findings.ShouldNotBeEmpty(); + findings.Count.ShouldBe(8); // S01-S08 + findings.ShouldAllBe(f => f.Status == FindingStatus.Pass || f.Status == FindingStatus.Warning); + findings.ShouldNotContain(f => f.Status == FindingStatus.Fail); + } + + [Fact] + public void OAuth21CompliantClientHasNoFailures() + { + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode, ConformanceReportGrantTypes.RefreshToken], + requirePkce: true, + allowPlainTextPkce: false, + redirectUris: ["https://example.com/callback"], + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.JsonWebKey], + requirePar: true, + requireDPoP: true, + dpopMode: ConformanceReportDPoPValidationMode.Nonce, + authCodeLifetime: 30, + allowOfflineAccess: true, + refreshTokenUsage: ConformanceReportTokenUsage.OneTimeOnly); + + var findings = assessor.AssessClient(client); + + findings.ShouldNotBeEmpty(); + findings.Count.ShouldBe(12); // C01-C12 + findings.ShouldNotContain(f => f.Status == FindingStatus.Fail); + } + + [Fact] + public void MinimallyConfiguredClientHasMultipleWarnings() + { + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.AuthorizationCode], + requirePkce: true, + allowPlainTextPkce: false, + redirectUris: ["https://example.com/callback"], + requireClientSecret: true, + secretTypes: [ConformanceReportSecretTypes.SharedSecret], + requirePar: false, + requireDPoP: false, + authCodeLifetime: 60, + allowOfflineAccess: false); + + var findings = assessor.AssessClient(client); + + // Should have some warnings for: PAR not required, no sender constraint, no refresh tokens, shared secret + var warnings = findings.Where(f => f.Status == FindingStatus.Warning).ToList(); + warnings.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void NonCompliantClientWithImplicitGrantFails() + { + var options = CreateDefaultServerOptions(); + var assessor = new OAuth21Assessor(options); + + var client = CreateDefaultClient( + grantTypes: [ConformanceReportGrantTypes.Implicit]); + + var findings = assessor.AssessClient(client); + + var grantTypeFinding = GetFinding(findings, "C01"); + grantTypeFinding.Status.ShouldBe(FindingStatus.Fail); + } + } +} diff --git a/conformance-report/test/Directory.Build.props b/conformance-report/test/Directory.Build.props new file mode 100644 index 000000000..8a724a8b3 --- /dev/null +++ b/conformance-report/test/Directory.Build.props @@ -0,0 +1,6 @@ + + + + enable + + diff --git a/identity-server/src/IdentityServer.ConformanceReport/ClientAdapter.cs b/identity-server/src/IdentityServer.ConformanceReport/ClientAdapter.cs new file mode 100644 index 000000000..886c47f0a --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/ClientAdapter.cs @@ -0,0 +1,60 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.ConformanceReport; + +/// +/// Adapts IdentityServer Client to ConformanceReportClient. +/// +internal static class ClientAdapter +{ + public static ConformanceReportClient ToConformanceReportClient(this Client client) => + new() + { + ClientId = client.ClientId, + ClientName = client.ClientName, + AllowedGrantTypes = client.AllowedGrantTypes.ToList(), + RequirePkce = client.RequirePkce, + AllowPlainTextPkce = client.AllowPlainTextPkce, + RedirectUris = client.RedirectUris.ToList(), + RequireClientSecret = client.RequireClientSecret, + ClientSecretTypes = client.ClientSecrets + .Select(s => s.Type) + .Distinct() + .ToList(), + RequirePushedAuthorization = client.RequirePushedAuthorization, + RequireDPoP = client.RequireDPoP, + DPoPValidationMode = MapDPoPMode(client.DPoPValidationMode), + AuthorizationCodeLifetime = client.AuthorizationCodeLifetime, + AllowOfflineAccess = client.AllowOfflineAccess, + RefreshTokenUsage = MapTokenUsage(client.RefreshTokenUsage), + AllowAccessTokensViaBrowser = client.AllowAccessTokensViaBrowser, + RequireRequestObject = client.RequireRequestObject + }; + + private static ConformanceReportTokenUsage MapTokenUsage(TokenUsage usage) => usage switch + { + TokenUsage.OneTimeOnly => ConformanceReportTokenUsage.OneTimeOnly, + _ => ConformanceReportTokenUsage.ReUse + }; + + private static ConformanceReportDPoPValidationMode MapDPoPMode( + DPoPTokenExpirationValidationMode mode) + { + var result = ConformanceReportDPoPValidationMode.None; + if (mode.HasFlag(DPoPTokenExpirationValidationMode.Nonce)) + { + result |= ConformanceReportDPoPValidationMode.Nonce; + } + + if (mode.HasFlag(DPoPTokenExpirationValidationMode.Iat)) + { + result |= ConformanceReportDPoPValidationMode.Iat; + } + + return result; + } +} diff --git a/identity-server/src/IdentityServer.ConformanceReport/IdentityServer.ConformanceReport.csproj b/identity-server/src/IdentityServer.ConformanceReport/IdentityServer.ConformanceReport.csproj new file mode 100644 index 000000000..ae1394912 --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/IdentityServer.ConformanceReport.csproj @@ -0,0 +1,20 @@ + + + + enable + Duende.IdentityServer.ConformanceReport + Duende.IdentityServer.ConformanceReport + Duende.IdentityServer.ConformanceReport + IdentityServer adapter for Duende ConformanceReport assessment + cr- + 0.1 + README.md + + + + + + + + + diff --git a/identity-server/src/IdentityServer.ConformanceReport/IdentityServerBuilderExtensions.cs b/identity-server/src/IdentityServer.ConformanceReport/IdentityServerBuilderExtensions.cs new file mode 100644 index 000000000..0136b0eb9 --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/IdentityServerBuilderExtensions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport; +using Duende.IdentityServer.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Duende.IdentityServer.ConformanceReport; + +/// +/// Extension methods for adding conformance to IdentityServer. +/// +public static class IdentityServerBuilderExtensions +{ + /// + /// Adds conformance assessment to IdentityServer. + /// + public static IIdentityServerBuilder AddConformanceReport( + this IIdentityServerBuilder builder, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var services = builder.Services; + + // Add core conformance services + _ = services.AddConformanceReport(configure); + + // Register the server options provider that adapts IdentityServerOptions + services.TryAddScoped>(sp => + { + var options = sp.GetRequiredService>().Value; + return () => options.ToConformanceReportServerOptions(); + }); + + // Register license info from IdentityServer license + _ = services.AddScoped(sp => + { + var license = sp.GetService(); + return license?.ToConformanceReportLicenseInfo() ?? new ConformanceReportLicenseInfo(); + }); + + // Register client store adapter + services.TryAddScoped(); + + return builder; + } + + /// + /// Converts an IdentityServerLicense to ConformanceReportLicenseInfo. + /// + internal static ConformanceReportLicenseInfo ToConformanceReportLicenseInfo(this IdentityServerLicense license) => + new() + { + CompanyName = license.CompanyName, + ContactInfo = license.ContactInfo, + SerialNumber = license.SerialNumber, + Expiration = license.Expiration, + Edition = license.Edition.ToString() + }; +} diff --git a/identity-server/src/IdentityServer.ConformanceReport/IdentityServerClientStore.cs b/identity-server/src/IdentityServer.ConformanceReport/IdentityServerClientStore.cs new file mode 100644 index 000000000..15f36ea9e --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/IdentityServerClientStore.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport; +using Duende.IdentityServer.Stores; + +namespace Duende.IdentityServer.ConformanceReport; + +/// +/// Adapts IdentityServer's client store to the conformance client store interface. +/// +#pragma warning disable CA1812 // IdentityServerClientStore is instantiated via DI +internal sealed class IdentityServerClientStore(IClientStore clientStore) : IConformanceReportClientStore +#pragma warning restore CA1812 +{ + public async Task> GetAllClientsAsync( + CancellationToken ct = default) + { + var clients = new List(); + await foreach (var client in clientStore.GetAllClientsAsync().WithCancellation(ct)) + { + clients.Add(client.ToConformanceReportClient()); + } + return clients; + } +} diff --git a/identity-server/src/IdentityServer.ConformanceReport/README.md b/identity-server/src/IdentityServer.ConformanceReport/README.md new file mode 100644 index 000000000..c306ac2ad --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/README.md @@ -0,0 +1,131 @@ +# Duende.IdentityServer.ConformanceReport + +_OAuth 2.1 and FAPI 2.0 Security Profile conformance assessment for Duende IdentityServer._ + +## Overview + +`Duende.IdentityServer.ConformanceReport` adds conformance assessment to your IdentityServer application. It evaluates your server and client configuration against OAuth 2.1 and FAPI 2.0 Security Profile requirements and generates an HTML report accessible via a protected endpoint. + +## Installation + +```bash +dotnet add package Duende.IdentityServer.ConformanceReport +``` + +## Setup + +### 1. Add to your IdentityServer configuration + +```csharp +builder.Services + .AddIdentityServer() + .AddInMemoryClients(clients) + .AddConformanceReport(options => + { + options.Enabled = true; + options.EnableOAuth21Assessment = true; + options.EnableFapi2SecurityAssessment = true; + + // Authorization is configured automatically - requires authenticated user by default + // Customize as needed: + options.ConfigureAuthorization = policy => policy + .RequireRole("Administrator") + .RequireClaim("department", "Compliance"); + }); +``` + +### 2. Map the endpoint + +```csharp +app.MapConformanceReport(); +``` + +### 3. Access the report + +Navigate to: `https://your-server/_duende/conformance-report` + +## Configuration Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Enabled` | `bool` | `false` | Enable/disable conformance endpoints | +| `EnableOAuth21Assessment` | `bool` | `true` | Enable OAuth 2.1 profile assessment | +| `EnableFapi2SecurityAssessment` | `bool` | `true` | Enable FAPI 2.0 Security Profile assessment | +| `PathPrefix` | `string` | `"_duende"` | Path prefix for conformance endpoints (without leading slash) | +| `ConfigureAuthorization` | `Action?` | Requires authenticated user | Configure authorization policy for the HTML report endpoint | +| `AuthorizationPolicyName` | `string` | `"ConformanceReport"` | ASP.NET Core authorization policy name (used internally) | +| `HostCompanyName` | `string?` | `null` | Optional display name of the host company to include in the report | +| `HostCompanyLogoUrl` | `Uri?` | `null` | Optional URL of the host company's logo | + +### Authorization Examples + +**Default (requires authenticated user):** +```csharp +options.Enabled = true; +// ConfigureAuthorization defaults to requiring authenticated user +``` + +**Require specific role:** +```csharp +options.ConfigureAuthorization = policy => policy.RequireRole("Admin"); +``` + +**Require multiple conditions:** +```csharp +options.ConfigureAuthorization = policy => policy + .RequireRole("Admin") + .RequireClaim("department", "IT"); +``` + +**Allow anonymous (for development/testing only):** +```csharp +options.ConfigureAuthorization = policy => + policy.RequireAssertion(_ => builder.Environment.IsDevelopment()); +``` + +**Manual policy registration (advanced scenarios):** +```csharp +// In AddConformanceReport: +options.ConfigureAuthorization = null; // Skip automatic registration + +// Then manually register the policy: +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("ConformanceReport", policy => + { + policy.Requirements.Add(new MyCustomRequirement()); + }); +}); +``` + +> **Important**: If you set `ConfigureAuthorization = null`, you **must** manually register a policy with the name specified in `AuthorizationPolicyName` (default: `"ConformanceReport"`). Otherwise, the endpoint will fail at runtime with a "policy not found" error. + +## Understanding the Report + +The HTML report displays: +- **Server Configuration**: Matrix showing server-level conformance rules +- **Client Configurations**: Matrix showing per-client conformance rules +- **Rule Legend**: Explanation of each rule ID +- **Notes**: Detailed messages for warnings and failures + +### Status Indicators + +| Symbol | Meaning | +|--------|---------| +| Pass | Requirement met | +| Fail | Requirement not met (non-conformant) | +| Warning | Recommended practice not followed | +| N/A | Rule not applicable | + +## Licensing + +Duende IdentityServer is source-available, but requires a paid [license](https://duendesoftware.com/products/identityserver) for production use. + +- **Development and Testing**: You are free to use and explore the code for development, testing, or personal projects without a license. +- **Production**: A license is required for production environments. +- **Free Community Edition**: A free Community Edition license is available for qualifying companies and non-profit organizations. Learn more [here](https://duendesoftware.com/products/communityedition). + +## Reporting Issues and Getting Support + +- For bug reports or feature requests, [use our developer community forum](https://duende.link/community). +- For security-related concerns, please contact us privately at: **security@duendesoftware.com**. diff --git a/identity-server/src/IdentityServer.ConformanceReport/ServerOptionsAdapter.cs b/identity-server/src/IdentityServer.ConformanceReport/ServerOptionsAdapter.cs new file mode 100644 index 000000000..3bc6361a9 --- /dev/null +++ b/identity-server/src/IdentityServer.ConformanceReport/ServerOptionsAdapter.cs @@ -0,0 +1,26 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.ConformanceReport; +using Duende.IdentityServer.Configuration; + +namespace Duende.IdentityServer.ConformanceReport; + +/// +/// Adapts IdentityServerOptions to ConformanceReportServerOptions. +/// +internal static class ServerOptionsAdapter +{ + public static ConformanceReportServerOptions ToConformanceReportServerOptions( + this IdentityServerOptions options) => new() + { + PushedAuthorizationEndpointEnabled = options.Endpoints.EnablePushedAuthorizationEndpoint, + PushedAuthorizationRequired = options.PushedAuthorization.Required, + PushedAuthorizationLifetime = options.PushedAuthorization.Lifetime, + MutualTlsEnabled = options.MutualTls.Enabled, + SupportedSigningAlgorithms = options.SupportedClientAssertionSigningAlgorithms.ToList(), + JwtValidationClockSkew = options.JwtValidationClockSkew, + EmitIssuerIdentificationResponseParameter = options.EmitIssuerIdentificationResponseParameter, + UseHttp303Redirects = true, // IdentityServer always uses HTTP 303 for redirects + }; +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/ConformanceReport/ConformanceReportIntegrationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/ConformanceReport/ConformanceReportIntegrationTests.cs new file mode 100644 index 000000000..45ca50fc6 --- /dev/null +++ b/identity-server/test/IdentityServer.IntegrationTests/ConformanceReport/ConformanceReportIntegrationTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Duende.ConformanceReport.Endpoints; +using Duende.IdentityServer.ConformanceReport; +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Duende.IdentityServer.IntegrationTests.ConformanceReport; + +/// +/// Integration tests for the IdentityServer adapter. +/// Verifies that an IdentityServer host with conformance report enabled +/// can successfully generate and serve the HTML report. +/// +public class ConformanceReportIntegrationTests : IAsyncLifetime +{ + private WebApplication _app = null!; + private HttpClient _client = null!; + + public async ValueTask InitializeAsync() + { + var clients = new List + { + new() + { + ClientId = "sample-client", + AllowedGrantTypes = GrantTypes.Code, + RequirePkce = true, + RedirectUris = { "https://localhost:5001/callback" }, + AllowedScopes = { "openid", "profile" } + } + }; + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddAuthorization(); + builder.Services + .AddIdentityServer(options => + { + options.EmitIssuerIdentificationResponseParameter = true; + }) + .AddInMemoryClients(clients) + .AddInMemoryIdentityResources([new IdentityResources.OpenId(), new IdentityResources.Profile()]) + .AddConformanceReport(options => + { + options.Enabled = true; + options.EnableOAuth21Assessment = true; + options.EnableFapi2SecurityAssessment = true; + options.ConfigureAuthorization = policy => + policy.RequireAssertion(_ => true); + }); + + _app = builder.Build(); + _app.UseRouting(); + _app.UseIdentityServer(); + _app.UseAuthorization(); + _app.MapConformanceReport(); + + await _app.StartAsync(); + _client = _app.GetTestServer().CreateClient(); + } + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); + + [Fact] + public async Task ConformanceReportEndpointReturnsHtmlReport() + { + var response = await _client.GetAsync("/_duende/conformance-report"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var contentType = response.Content.Headers.ContentType; + _ = contentType.ShouldNotBeNull(); + contentType.MediaType.ShouldBe("text/html"); + + var html = await response.Content.ReadAsStringAsync(); + html.ShouldNotBeEmpty(); + html.ShouldContain(""); + html.ShouldContain("OAuth 2.1"); + html.ShouldContain("FAPI 2.0"); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj index 74371f075..a4c1d02c6 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj +++ b/identity-server/test/IdentityServer.IntegrationTests/IdentityServer.IntegrationTests.csproj @@ -41,5 +41,6 @@ + diff --git a/products.slnx b/products.slnx index 83ba90efa..bd7db6cfd 100644 --- a/products.slnx +++ b/products.slnx @@ -14,6 +14,13 @@ + + + + + + + @@ -59,13 +66,13 @@ - + - + @@ -77,7 +84,7 @@ - +