Add ConformanceReport — OAuth 2.1 and FAPI 2.0 Security Profile conformance assessment for IdentityServer

This commit is contained in:
Damian Hickey 2026-02-19 16:22:33 +01:00
parent dcbf987572
commit 9864b1c6dc
60 changed files with 6805 additions and 0 deletions

View file

@ -14,6 +14,7 @@
<PackageVersion Include="Duende.IdentityModel" Version="8.0.0" />
<PackageVersion Include="Duende.IdentityModel.OidcClient" Version="7.0.0" />
<PackageVersion Include="Duende.IdentityServer" Version="7.4.0-preview.2" />
<PackageVersion Include="Duende.IdentityServer.Storage" Version="7.4.0-preview.2" />
<PackageVersion Include="Duende.Private.Licensing" Version="1.0.0" />
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
<PackageVersion Include="Markdig" Version="0.43.0" />
@ -82,6 +83,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.12.0-beta.2" />
<PackageVersion Include="OpenTelemetry" Version="1.12.0" />
<PackageVersion Include="PublicApiGenerator" Version="11.1.0" />
<PackageVersion Include="Duende.RazorSlices" Version="1.0.0" />
<PackageVersion Include="ReverseMarkdown" Version="4.7.0" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageVersion Include="Serilog" Version="4.2.0" />

View file

@ -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.

View file

@ -0,0 +1,11 @@
{
"solution": {
"path": "..\\products.slnx",
"projects": [
"conformance-report\\src\\ConformanceReport\\ConformanceReport.csproj",
"conformance-report\\test\\ConformanceReport.Tests\\ConformanceReport.Tests.csproj",
"identity-server\\src\\IdentityServer.ConformanceReport\\IdentityServer.ConformanceReport.csproj",
"shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj"
]
}
}

View file

@ -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;
/// <summary>
/// Configures the authorization policy for the conformance report endpoint.
/// </summary>
internal class ConfigureConformanceReportAuthorizationPolicy
: IConfigureOptions<AuthorizationOptions>
{
private readonly ConformanceReportOptions _conformanceOptions;
public ConfigureConformanceReportAuthorizationPolicy(
IOptions<ConformanceReportOptions> 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);
}
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<AssemblyName>Duende.ConformanceReport</AssemblyName>
<PackageId>Duende.ConformanceReport</PackageId>
<Description>Conformance assessment for FAPI 2.0 and OAuth 2.1 profiles</Description>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<EmitCompilerGeneratedFiles>false</EmitCompilerGeneratedFiles>
<OutputType>Library</OutputType>
<NoWarn>$(NoWarn);NETSDK1086</NoWarn>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Duende.RazorSlices" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ConformanceReport.Tests" />
<InternalsVisibleTo Include="Duende.IdentityServer.ConformanceReport" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,42 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport;
/// <summary>
/// Represents a client for conformance assessment.
/// </summary>
internal sealed record ConformanceReportClient
{
public required string ClientId { get; init; }
public string? ClientName { get; init; }
public required IReadOnlyCollection<string> AllowedGrantTypes { get; init; }
public required bool RequirePkce { get; init; }
public required bool AllowPlainTextPkce { get; init; }
public required IReadOnlyCollection<string> RedirectUris { get; init; }
public required bool RequireClientSecret { get; init; }
public required IReadOnlyCollection<string> 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; }
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport;
/// <summary>
/// Constants for the conformance assessment feature.
/// </summary>
internal static class ConformanceReportConstants
{
/// <summary>
/// The conformance feature path segment.
/// </summary>
public const string FeaturePath = "conformance-report";
/// <summary>
/// The API version for conformance endpoints.
/// </summary>
public const string ApiVersion = "v1";
/// <summary>
/// The unique identifier for the conformance report (for GRC tool integration).
/// </summary>
public const string ReportId = "conformance-assessment";
/// <summary>
/// The display name for the conformance report (for GRC tool integration).
/// </summary>
public const string ReportName = "Conformance Assessment";
}

View file

@ -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
}

View file

@ -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";
}

View file

@ -0,0 +1,35 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport;
/// <summary>
/// Represents license information for the conformance report.
/// </summary>
public sealed record ConformanceReportLicenseInfo
{
/// <summary>
/// The company name from the license.
/// </summary>
public string? CompanyName { get; init; }
/// <summary>
/// The contact information from the license.
/// </summary>
public string? ContactInfo { get; init; }
/// <summary>
/// The license serial number.
/// </summary>
public int? SerialNumber { get; init; }
/// <summary>
/// The license expiration date.
/// </summary>
public DateTime? Expiration { get; init; }
/// <summary>
/// The license edition name.
/// </summary>
public string? Edition { get; init; }
}

View file

@ -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;
/// <summary>
/// Options for the conformance assessment feature.
/// </summary>
public class ConformanceReportOptions
{
/// <summary>
/// Enable conformance endpoints. Requires valid license.
/// Default: false
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Enable OAuth 2.1 conformance assessment.
/// Default: true
/// </summary>
public bool EnableOAuth21Assessment { get; set; } = true;
/// <summary>
/// Enable FAPI 2.0 Security Profile conformance assessment.
/// Default: true
/// </summary>
public bool EnableFapi2SecurityAssessment { get; set; } = true;
/// <summary>
/// Path prefix for conformance endpoints (without leading slash).
/// Default: "_duende"
/// </summary>
public string PathPrefix { get; set; } = "_duende";
/// <summary>
/// ASP.NET Core authorization policy name for the HTML report endpoint.
/// Default: "ConformanceReport"
/// </summary>
public string AuthorizationPolicyName { get; set; } = "ConformanceReport";
/// <summary>
/// Configures the authorization policy for the conformance report endpoint.
/// By default, requires an authenticated user.
/// </summary>
/// <remarks>
/// <para>
/// If set to <c>null</c>, the authorization policy will NOT be automatically registered.
/// In this case, you must manually register a policy with the name specified in
/// <see cref="AuthorizationPolicyName"/> (default: "ConformanceReport"), or the endpoint
/// will fail at runtime with a "policy not found" error.
/// </para>
/// <para>
/// Setting to <c>null</c> is useful when you need to register the policy yourself with
/// custom logic that cannot be expressed through <see cref="AuthorizationPolicyBuilder"/>.
/// </para>
/// </remarks>
/// <example>
/// // 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())));
/// </example>
public Action<AuthorizationPolicyBuilder>? ConfigureAuthorization { get; set; }
= policy => policy.RequireAuthenticatedUser();
/// <summary>
/// Optional display name of the host company to include in the report. This is for personalization and has no effect on the assessment results.
/// </summary>
public string? HostCompanyName { get; set; }
/// <summary>
/// Gets or sets the URL of the host company's logo.
/// </summary>
/// <remarks>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.</remarks>
public Uri? HostCompanyLogoUrl { get; set; }
}

View file

@ -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";
}

View file

@ -0,0 +1,26 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport;
/// <summary>
/// Represents server-level options for conformance assessment.
/// </summary>
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<string> SupportedSigningAlgorithms { get; init; }
public required TimeSpan JwtValidationClockSkew { get; init; }
public required bool EmitIssuerIdentificationResponseParameter { get; init; }
public required bool UseHttp303Redirects { get; init; }
}

View file

@ -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;
/// <summary>
/// Extension methods for adding conformance services to the DI container.
/// </summary>
public static class ConformanceReportServiceCollectionExtensions
{
/// <summary>
/// Adds core conformance services to the service collection.
/// </summary>
public static IServiceCollection AddConformanceReport(
this IServiceCollection services,
Action<ConformanceReportOptions>? configure = null)
{
_ = services.AddOptions<ConformanceReportOptions>();
if (configure != null)
{
_ = services.Configure(configure);
}
// Register HTTP context accessor if not already registered
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// Register assessment service
_ = services.AddTransient<ConformanceReportAssessmentService>();
// Register endpoint
_ = services.AddTransient<ConformanceReportEndpoint>();
// Register authorization policy configuration
_ = services.AddSingleton<IConfigureOptions<AuthorizationOptions>,
ConfigureConformanceReportAuthorizationPolicy>();
return services;
}
}

View file

@ -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
}

View file

@ -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;
/// <summary>
/// Endpoint for generating conformance assessment reports.
/// </summary>
internal sealed partial class ConformanceReportEndpoint
{
private readonly ConformanceReportAssessmentService _assessmentService;
private readonly ConformanceReportOptions _options;
private readonly ILogger<ConformanceReportEndpoint> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ConformanceReportEndpoint"/> class.
/// </summary>
public ConformanceReportEndpoint(
ConformanceReportAssessmentService assessmentService,
IOptions<ConformanceReportOptions> options,
ILogger<ConformanceReportEndpoint> 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);
/// <summary>
/// Processes requests for the HTML conformance report.
/// </summary>
public async Task<IResult> 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);
}
}
}

View file

@ -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;
/// <summary>
/// Extension methods for mapping conformance assessment endpoints.
/// </summary>
public static class ConformanceReportEndpointExtensions
{
/// <summary>
/// Maps the conformance assessment endpoints.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>A builder for configuring the endpoint group.</returns>
public static RouteGroupBuilder MapConformanceReport(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<ConformanceReportOptions>>().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;
}
}

View file

@ -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;

View file

@ -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<IEnumerable<ConformanceReportClient>> GetAllClientsAsync(CancellationToken ct = default);
}

View file

@ -0,0 +1,32 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents the conformance assessment results for a single client.
/// </summary>
public sealed class ClientResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ClientResult"/> class.
/// </summary>
internal ClientResult() { }
public required string ClientId { get; init; }
/// <summary>
/// The client name, if available.
/// </summary>
public string? ClientName { get; internal init; }
/// <summary>
/// The overall conformance status for this client.
/// </summary>
public required ConformanceReportStatus Status { get; init; }
/// <summary>
/// The list of findings for this client.
/// </summary>
public required IReadOnlyList<Finding> Findings { get; init; }
}

View file

@ -0,0 +1,20 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents a conformance profile that can be assessed.
/// </summary>
internal enum ConformanceReportProfile
{
/// <summary>
/// OAuth 2.1 specification profile
/// </summary>
OAuth21,
/// <summary>
/// FAPI 2.0 Security Profile
/// </summary>
Fapi2Security
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Container for conformance profile results.
/// </summary>
public sealed class ConformanceReportProfiles
{
/// <summary>
/// Initializes a new instance of the <see cref="ConformanceReportProfiles"/> class.
/// </summary>
internal ConformanceReportProfiles() { }
/// <summary>
/// OAuth 2.1 conformance assessment results.
/// </summary>
public ProfileResult? OAuth21 { get; internal init; }
/// <summary>
/// FAPI 2.0 Security Profile conformance assessment results.
/// </summary>
public ProfileResult? Fapi2Security { get; internal init; }
}

View file

@ -0,0 +1,70 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents a complete conformance assessment report.
/// </summary>
public sealed class ConformanceReportResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ConformanceReportResult"/> class.
/// </summary>
internal ConformanceReportResult() { }
/// <summary>
/// The unique identifier for this report type (for GRC tool integration).
/// </summary>
public string Id { get; internal init; } = ConformanceReportConstants.ReportId;
/// <summary>
/// The display name for this report (for GRC tool integration).
/// </summary>
public string Name { get; internal init; } = ConformanceReportConstants.ReportName;
/// <summary>
/// The license information (if available).
/// </summary>
public ConformanceReportLicenseInfo? License { get; init; }
/// <summary>
/// The version of the Conformance Report tool.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// The absolute URL to the HTML report endpoint.
/// </summary>
public required Uri Url { get; init; }
/// <summary>
/// The overall conformance status across all profiles.
/// </summary>
public required ConformanceReportStatus Status { get; init; }
/// <summary>
/// The timestamp when this report was generated.
/// </summary>
public required DateTimeOffset AssessedAt { get; init; }
/// <summary>
/// The results for each conformance profile.
/// </summary>
public required ConformanceReportProfiles Profiles { get; init; }
/// <summary>
/// Overall summary statistics across all profiles.
/// </summary>
public required OverallSummary OverallSummary { get; init; }
/// <summary>
/// The name of the hosting company (optional).
/// </summary>
public string? HostCompanyName { get; init; }
/// <summary>
/// The URL to the hosting company's logo (optional).
/// </summary>
public Uri? HostCompanyLogoUrl { get; init; }
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents the overall conformance status for a report, profile, server, or client.
/// </summary>
public enum ConformanceReportStatus
{
/// <summary>
/// All requirements are satisfied.
/// </summary>
Pass,
/// <summary>
/// Some recommendations are not followed, but no requirements are violated.
/// </summary>
Warn,
/// <summary>
/// One or more requirements are not satisfied.
/// </summary>
Fail
}

View file

@ -0,0 +1,40 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents a single conformance finding for a specific rule.
/// </summary>
public sealed class Finding
{
/// <summary>
/// Initializes a new instance of the <see cref="Finding"/> class.
/// </summary>
internal Finding() { }
/// <summary>
/// The unique identifier for this rule (e.g., "S01", "C03").
/// </summary>
public required string RuleId { get; init; }
/// <summary>
/// A human-readable name for this rule.
/// </summary>
public required string RuleName { get; init; }
/// <summary>
/// The status of this finding.
/// </summary>
public required FindingStatus Status { get; init; }
/// <summary>
/// A detailed message explaining the finding.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Optional recommendation for remediation when the status is Fail or Warning.
/// </summary>
public string? Recommendation { get; internal init; }
}

View file

@ -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>
/// Represents the status of a conformance finding.
/// </summary>
public enum FindingStatus
{
/// <summary>
/// The requirement is satisfied.
/// </summary>
Pass,
/// <summary>
/// The requirement is not satisfied.
/// </summary>
Fail,
/// <summary>
/// A potential issue was detected that may affect conformance.
/// </summary>
Warning,
/// <summary>
/// The requirement is not applicable to this configuration.
/// </summary>
NotApplicable,
/// <summary>
/// An error occurred while assessing this requirement.
/// </summary>
Error
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Overall summary statistics for the conformance report.
/// </summary>
public sealed class OverallSummary
{
/// <summary>
/// Initializes a new instance of the <see cref="OverallSummary"/> class.
/// </summary>
internal OverallSummary() { }
/// <summary>
/// The total number of clients assessed.
/// </summary>
public int TotalClients { get; internal init; }
/// <summary>
/// Summary statistics for OAuth 2.1 profile.
/// </summary>
public required ProfileStatusSummary OAuth21 { get; init; }
/// <summary>
/// Summary statistics for FAPI 2.0 Security Profile.
/// </summary>
public required ProfileStatusSummary Fapi2Security { get; init; }
}

View file

@ -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;
/// <summary>
/// Represents the conformance assessment results for a specific profile.
/// </summary>
public sealed class ProfileResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ProfileResult"/> class.
/// </summary>
internal ProfileResult() { }
/// <summary>
/// The display name for this profile (e.g., "OAuth 2.1", "FAPI 2.0 Security Profile").
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The specification version assessed (e.g., "draft-14", "1.0").
/// </summary>
public required string SpecVersion { get; init; }
/// <summary>
/// The specification status (e.g., "draft", "final").
/// </summary>
public required string SpecStatus { get; init; }
/// <summary>
/// Optional note about the profile (e.g., draft specification warning).
/// </summary>
public string? Note { get; internal init; }
/// <summary>
/// The overall conformance status for this profile.
/// </summary>
public required ConformanceReportStatus Status { get; init; }
/// <summary>
/// Server-level conformance results.
/// </summary>
public required ServerResult Server { get; init; }
/// <summary>
/// Per-client conformance results.
/// </summary>
public required IReadOnlyList<ClientResult> Clients { get; init; }
/// <summary>
/// Summary statistics for this profile.
/// </summary>
public required ProfileSummary Summary { get; init; }
}

View file

@ -0,0 +1,30 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Status summary for a specific conformance profile.
/// </summary>
public sealed class ProfileStatusSummary
{
/// <summary>
/// Initializes a new instance of the <see cref="ProfileStatusSummary"/> class.
/// </summary>
internal ProfileStatusSummary() { }
/// <summary>
/// The number of clients that pass all requirements.
/// </summary>
public int Passing { get; internal init; }
/// <summary>
/// The number of clients that have warnings but no failures.
/// </summary>
public int Warning { get; internal init; }
/// <summary>
/// The number of clients that fail one or more requirements.
/// </summary>
public int Failing { get; internal init; }
}

View file

@ -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>
/// Summary statistics for a conformance profile assessment.
/// </summary>
public sealed class ProfileSummary
{
/// <summary>
/// Initializes a new instance of the <see cref="ProfileSummary"/> class.
/// </summary>
internal ProfileSummary() { }
/// <summary>
/// The total number of clients assessed.
/// </summary>
public int TotalClients { get; internal init; }
/// <summary>
/// The number of clients that pass all requirements.
/// </summary>
public int PassingClients { get; internal init; }
/// <summary>
/// The number of clients that have warnings but no failures.
/// </summary>
public int WarningClients { get; internal init; }
/// <summary>
/// The number of clients that fail one or more requirements.
/// </summary>
public int FailingClients { get; internal init; }
}

View file

@ -0,0 +1,25 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport.Models;
/// <summary>
/// Represents the server-level conformance assessment results.
/// </summary>
public sealed class ServerResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ServerResult"/> class.
/// </summary>
internal ServerResult() { }
/// <summary>
/// The overall status of server-level conformance.
/// </summary>
public required ConformanceReportStatus Status { get; init; }
/// <summary>
/// The list of server-level conformance findings.
/// </summary>
public required IReadOnlyList<Finding> Findings { get; init; }
}

View file

@ -0,0 +1,12 @@
{
"profiles": {
"ConformanceReport": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:54651;http://localhost:54652"
}
}
}

View file

@ -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;
/// <summary>
/// Service for assessing configuration conformance against OAuth 2.1 and FAPI 2.0 profiles.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="ConformanceReportAssessmentService"/> class.
/// </summary>
public ConformanceReportAssessmentService(
IOptions<ConformanceReportOptions> conformanceOptions,
Func<ConformanceReportServerOptions> 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);
}
/// <summary>
/// Generates a complete conformance assessment report.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A conformance report containing the assessment results.</returns>
public async Task<ConformanceReportResult> 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
};
}
/// <summary>
/// Generates a conformance assessment report for a specific profile.
/// </summary>
/// <param name="profile">The profile to assess.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A profile result containing the assessment findings.</returns>
public async Task<ProfileResult> 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")
};
}
/// <summary>
/// Assesses a single client against a specific profile.
/// </summary>
/// <param name="profile">The profile to assess against.</param>
/// <param name="client">The client to assess.</param>
/// <returns>A client result containing the assessment findings.</returns>
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<ConformanceReportClient> 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<ConformanceReportClient> 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<Finding> 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<ClientResult> 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<ConformanceReportStatus>();
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<ClientResult> 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<AssemblyInformationalVersionAttribute>()
?.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;
}
}

View file

@ -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;
/// <summary>
/// Assesses configuration against the FAPI 2.0 Security Profile specification.
/// See: https://openid.net/specs/fapi-security-profile-2_0-final.html
/// </summary>
internal class Fapi2SecurityAssessor(ConformanceReportServerOptions options)
{
// FAPI 2.0 requires asymmetric algorithms only, PS256 or ES256 recommended
private static readonly HashSet<string> 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;
/// <summary>
/// Assesses server-level configuration against FAPI 2.0 Security Profile requirements.
/// </summary>
public IReadOnlyList<Finding> AssessServer()
{
var findings = new List<Finding>
{
// 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;
}
/// <summary>
/// Assesses a client's configuration against FAPI 2.0 Security Profile requirements.
/// </summary>
public IReadOnlyList<Finding> AssessClient(ConformanceReportClient client)
{
var findings = new List<Finding>
{
// 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<string>
{
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<string>();
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."
};
}
}

View file

@ -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;
/// <summary>
/// Assesses configuration against the OAuth 2.1 specification.
/// </summary>
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<string> 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;
/// <summary>
/// Assesses server-level configuration against OAuth 2.1 requirements.
/// </summary>
public IReadOnlyList<Finding> AssessServer()
{
var findings = new List<Finding>();
// 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;
}
/// <summary>
/// Assesses a client's configuration against OAuth 2.1 requirements.
/// </summary>
public IReadOnlyList<Finding> AssessClient(ConformanceReportClient client)
{
var findings = new List<Finding>();
// 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<string>
{
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<string>();
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
}

View file

@ -0,0 +1,31 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
namespace Duende.ConformanceReport;
/// <summary>
/// Signing algorithm constants used for conformance assessment.
/// These match the values from Microsoft.IdentityModel.Tokens.SecurityAlgorithms.
/// </summary>
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";
}

View file

@ -0,0 +1,883 @@
@using Duende.ConformanceReport.Slices
@using System.Globalization
@inherits RazorSlice<ConformanceReportResult>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conformance Assessment Report</title>
<style>
@GetStyles()
</style>
</head>
<body>
<div class="document">
<!-- Document Header -->
<header class="document-header">
<div class="header-row">
<div class="header-brand">
@if (!string.IsNullOrWhiteSpace(Model.HostCompanyName) || Model.HostCompanyLogoUrl is not null)
{
<div class="host-company-brand">
@if (Model.HostCompanyLogoUrl is not null)
{
<img src="@Model.HostCompanyLogoUrl.ToString()" alt="@(Model.HostCompanyName ?? "Company Logo")" class="host-company-logo" />
}
@if (!string.IsNullOrWhiteSpace(Model.HostCompanyName))
{
<span class="host-company-text">@Model.HostCompanyName</span>
}
</div>
}
<span class="brand-text">Duende IdentityServer</span>
</div>
<div class="header-meta">
<span class="doc-id">Document ID: @GenerateDocumentId()</span>
</div>
</div>
<div class="header-divider"></div>
<h1 class="document-title">Conformance Assessment Report</h1>
<div class="header-divider"></div>
</header>
<!-- Document Information -->
<section class="document-info">
<table class="info-table">
<tbody>
<tr>
<th>License</th>
<td>@GetLicenseInfo()</td>
</tr>
<tr>
<th>Report URL</th>
<td>@Model.Url.ToString()</td>
</tr>
<tr>
<th>Assessment Date</th>
<td>@FormatDateTime(Model.AssessedAt, includeDate: true)</td>
</tr>
<tr>
<th>Tool Version</th>
<td>@Model.Version</td>
</tr>
<tr>
<th>Profiles Assessed</th>
<td>@GetProfilesAssessed()</td>
</tr>
</tbody>
</table>
</section>
<!-- Report Summary -->
<section class="report-summary">
<h2>Report Summary</h2>
<div class="summary-box @GetStatusClass(Model.Status)">
<div class="summary-status">
<span class="status-label">Overall Status:</span>
<span class="status-value">@GetStatusText(Model.Status)</span>
</div>
<p class="summary-description">
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.
</p>
<div class="summary-stats">
<div class="stat">
<span class="stat-value">@Model.OverallSummary.TotalClients</span>
<span class="stat-label">Clients Assessed</span>
</div>
@if (Model.Profiles.OAuth21 is not null)
{
<div class="stat">
<span class="stat-value">@Model.Profiles.OAuth21.Summary.PassingClients / @Model.Profiles.OAuth21.Summary.TotalClients</span>
<span class="stat-label">OAuth 2.1 Passing</span>
</div>
}
@if (Model.Profiles.Fapi2Security is not null)
{
<div class="stat">
<span class="stat-value">@Model.Profiles.Fapi2Security.Summary.PassingClients / @Model.Profiles.Fapi2Security.Summary.TotalClients</span>
<span class="stat-label">FAPI 2.0 Passing</span>
</div>
}
</div>
</div>
</section>
<!-- Profile Results -->
@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))
}
<!-- Document Footer -->
<footer class="document-footer">
<div class="footer-divider"></div>
<div class="footer-content">
<div class="footer-left">
<p>Generated by Duende Conformance Report v<span>@Model.Version</span></p>
<p>@FormatDateTime(Model.AssessedAt, includeDate: true)</p>
</div>
<div class="footer-right">
<p>Duende Software</p>
<p>https://duendesoftware.com</p>
</div>
</div>
</footer>
</div>
</body>
</html>
@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<string>();
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<string>(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;
}
}
""");
}

View file

@ -0,0 +1,181 @@
@inherits RazorSlice<IReadOnlyList<ClientResult>>
@{
// 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<string, int>(); // 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));
}
}
}
}
<div class="matrix-section">
<table class="matrix-table">
<thead>
<tr>
<th class="entity-col">Client</th>
@foreach (var ruleId in allRuleIds)
{
<th title="@ruleId">@ruleId</th>
}
<th class="status-col">Status</th>
</tr>
</thead>
<tbody>
@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;
<tr>
<td class="entity-col">
@displayName
<span class="entity-id">@client.ClientId</span>
</td>
@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}";
<td class="@cellClass" title="@title">
@cellText@if (hasFootnote)
{<a href="#client-finding-@footnoteMap[key]" class="ref-link">[@footnoteMap[key]]</a>}
</td>
}
else
{
<td class="cell-na" title="Not applicable">N/A</td>
}
}
<td class="status-col @statusClass">@statusText</td>
</tr>
}
</tbody>
</table>
</div>
@if (allRuleIds.Count > 0)
{
<div class="rule-legend">
<h4>Rule Reference</h4>
<div class="legend-grid">
@{
// 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)
{
<div class="legend-item">
<span class="legend-code">@finding.RuleId</span>
<span class="legend-text">@finding.RuleName</span>
</div>
}
</div>
</div>
}
@if (footnotes.Count > 0)
{
// Group footnotes by client
var groupedByClient = footnotes
.GroupBy(f => new { f.ClientId, f.ClientName })
.ToList();
<div class="findings-section">
<h4>Client Configuration Findings</h4>
@foreach (var clientGroup in groupedByClient)
{
<div class="finding-group">
<div class="finding-group-header">@clientGroup.Key.ClientName <span class="entity-id">(@clientGroup.Key.ClientId)</span></div>
<ul class="finding-list">
@foreach (var (clientId, clientName, finding) in clientGroup)
{
var key = $"{clientId}_{finding.RuleId}";
var num = footnoteMap[key];
<li class="finding-item" id="client-finding-@num">
<span class="finding-number">@num.</span>
<span class="finding-action">@(!string.IsNullOrEmpty(finding.Recommendation) ? finding.Recommendation : finding.RuleName)</span>
<span class="finding-detail">@finding.Message</span>
</li>
}
</ul>
</div>
}
</div>
}
@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",
_ => "?"
};
}

View file

@ -0,0 +1,23 @@
@using Duende.ConformanceReport.Slices
@inherits RazorSlice<ClientResult>
@{
var statusClass = GetStatusClass(Model.Status);
var displayName = !string.IsNullOrEmpty(Model.ClientName)
? $"{Model.ClientName} ({Model.ClientId})"
: Model.ClientId;
}
<details class="client @statusClass">
<summary><span class="status-indicator @statusClass"></span> @displayName</summary>
@await RenderPartialAsync(_FindingsTable.Create(Model.Findings))
</details>
@functions {
private string GetStatusClass(ConformanceReportStatus status) => status switch
{
ConformanceReportStatus.Pass => "pass",
ConformanceReportStatus.Warn => "warning",
ConformanceReportStatus.Fail => "fail",
_ => "unknown"
};
}

View file

@ -0,0 +1,50 @@
@inherits RazorSlice<IReadOnlyList<Finding>>
<table class="findings">
<thead>
<tr>
<th>Rule</th>
<th>Name</th>
<th>Status</th>
<th>Message</th>
<th>Recommendation</th>
</tr>
</thead>
<tbody>
@foreach (var finding in Model)
{
var statusClass = GetStatusClass(finding.Status);
var statusText = GetStatusText(finding.Status);
<tr>
<td class="rule-id">@finding.RuleId</td>
<td>@finding.RuleName</td>
<td class="status @statusClass">@statusText</td>
<td>@finding.Message</td>
<td>@(finding.Recommendation ?? "-")</td>
</tr>
}
</tbody>
</table>
@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"
};
}

View file

@ -0,0 +1,54 @@
@using Duende.ConformanceReport.Slices
@inherits RazorSlice<ProfileResult>
@{
var statusClass = GetStatusClass(Model.Status);
var statusText = GetStatusText(Model.Status);
}
<section class="profile-section">
<div class="profile-header">
<h2 class="profile-title">@Model.Name</h2>
<span class="profile-status @statusClass">@statusText</span>
</div>
<p class="profile-meta">
Specification Version: @Model.SpecVersion (@Model.SpecStatus)
</p>
@if (!string.IsNullOrEmpty(Model.Note))
{
<p class="profile-note">@Model.Note</p>
}
<div class="findings-section">
<h3>Server Configuration Assessment</h3>
@await RenderPartialAsync(_ServerConfigurationMatrix.Create(Model.Server.Findings))
</div>
@if (Model.Clients.Count > 0)
{
<div class="findings-section">
<h3>Client Configuration Assessment</h3>
@await RenderPartialAsync(_ClientConfigurationMatrix.Create(Model.Clients))
</div>
}
</section>
@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"
};
}

View file

@ -0,0 +1,129 @@
@inherits RazorSlice<IReadOnlyList<Finding>>
@{
// 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<Finding>();
var footnoteIndex = 1;
var footnoteMap = new Dictionary<string, int>(); // 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);
}
}
}
<div class="matrix-section">
<table class="matrix-table">
<thead>
<tr>
<th class="entity-col">Component</th>
@foreach (var finding in findings)
{
<th title="@finding.RuleName">@finding.RuleId</th>
}
<th class="status-col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td class="entity-col">Authorization Server</td>
@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}";
<td class="@cellClass" title="@title">
@statusText@if (hasFootnote)
{<a href="#server-finding-@footnoteMap[finding.RuleId]" class="ref-link">[@footnoteMap[finding.RuleId]]</a>}
</td>
}
<td class="status-col @GetOverallStatusClass(findings)">@GetOverallStatusText(findings)</td>
</tr>
</tbody>
</table>
</div>
@if (findings.Count > 0)
{
<div class="rule-legend">
<h4>Rule Reference</h4>
<div class="legend-grid">
@foreach (var finding in findings)
{
<div class="legend-item">
<span class="legend-code">@finding.RuleId</span>
<span class="legend-text">@finding.RuleName</span>
</div>
}
</div>
</div>
}
@if (footnotes.Count > 0)
{
<div class="findings-section">
<h4>Server Configuration Findings</h4>
<div class="finding-group">
<ul class="finding-list">
@{ var findingNum = 1; }
@foreach (var finding in footnotes)
{
<li class="finding-item" id="server-finding-@findingNum">
<span class="finding-number">@findingNum.</span>
<span class="finding-action">@(!string.IsNullOrEmpty(finding.Recommendation) ? finding.Recommendation : finding.RuleName)</span>
<span class="finding-detail">@finding.Message</span>
</li>
findingNum++;
}
</ul>
</div>
</div>
}
@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<Finding> 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<Finding> 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";
}
}

View file

@ -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

View file

@ -0,0 +1,13 @@
<Project>
<Import Project="../../src.props" />
<PropertyGroup>
<Nullable>enable</Nullable>
<AssemblyName>Duende.ConformanceReport.$(MSBuildProjectName)</AssemblyName>
<PackageId>Duende.ConformanceReport.$(MSBuildProjectName)</PackageId>
<RootNamespace>Duende.ConformanceReport</RootNamespace>
<MinVerMinimumMajorMinor>0.1</MinVerMinimumMajorMinor>
<MinVerTagPrefix>cr-</MinVerTagPrefix>
<PackageTags>OAuth 2.0;OpenID Connect;Security;Identity;IdentityServer;Conformance</PackageTags>
<Product>Duende Conformance Report</Product>
</PropertyGroup>
</Project>

View file

@ -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<IOptions<AuthorizationOptions>>().Value;
var conformanceOptions = provider.GetRequiredService<IOptions<ConformanceReportOptions>>().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<IOptions<AuthorizationOptions>>().Value;
var conformanceOptions = provider.GetRequiredService<IOptions<ConformanceReportOptions>>().Value;
// Act
var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName);
// Assert
_ = policy.ShouldNotBeNull();
var roleRequirement = policy.Requirements.OfType<RolesAuthorizationRequirement>().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<IOptions<AuthorizationOptions>>().Value;
var conformanceOptions = provider.GetRequiredService<IOptions<ConformanceReportOptions>>().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<IOptions<AuthorizationOptions>>().Value;
var conformanceOptions = provider.GetRequiredService<IOptions<ConformanceReportOptions>>().Value;
// Act
var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName);
// Assert
_ = policy.ShouldNotBeNull();
var roleRequirement = policy.Requirements.OfType<RolesAuthorizationRequirement>().SingleOrDefault();
_ = roleRequirement.ShouldNotBeNull();
roleRequirement.AllowedRoles.ShouldContain("Admin");
var claimRequirement = policy.Requirements.OfType<ClaimsAuthorizationRequirement>().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<IOptions<AuthorizationOptions>>().Value;
var conformanceOptions = provider.GetRequiredService<IOptions<ConformanceReportOptions>>().Value;
// Act
var policy = authOptions.GetPolicy(conformanceOptions.AuthorizationPolicyName);
// Assert
_ = policy.ShouldNotBeNull();
var assertionRequirement = policy.Requirements.OfType<AssertionRequirement>().SingleOrDefault();
_ = assertionRequirement.ShouldNotBeNull();
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>ConformanceReport.Tests</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ConformanceReport\ConformanceReport.csproj" />
<ProjectReference Include="..\..\..\identity-server\src\IdentityServer.ConformanceReport\IdentityServer.ConformanceReport.csproj" />
</ItemGroup>
</Project>

View file

@ -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<ConformanceReportEndpoint>.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<ConformanceReportClient> clients) : IConformanceReportClientStore
{
public Task<IEnumerable<ConformanceReportClient>> 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<Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult>();
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<Microsoft.AspNetCore.Http.HttpResults.NotFound>();
}
[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");
}
}
}

View file

@ -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<string>? 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<ConformanceReportClient>? 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<ConformanceReportClient> clients) : IConformanceReportClientStore
{
public Task<IEnumerable<ConformanceReportClient>> 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);
}
}
}

View file

@ -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<string>? 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<string>? grantTypes = null,
bool requirePkce = true,
bool allowPlainTextPkce = false,
IReadOnlyCollection<string>? redirectUris = null,
bool requireClientSecret = true,
IReadOnlyCollection<string>? 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<Finding> 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);
}
}
}

View file

@ -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<string>? 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<string>? grantTypes = null,
bool requirePkce = true,
bool allowPlainTextPkce = false,
IReadOnlyCollection<string>? redirectUris = null,
bool requireClientSecret = true,
IReadOnlyCollection<string>? 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<Finding> 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);
}
}
}

View file

@ -0,0 +1,6 @@
<Project>
<Import Project="../../test.props" />
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -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;
/// <summary>
/// Adapts IdentityServer Client to ConformanceReportClient.
/// </summary>
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;
}
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Nullable>enable</Nullable>
<PackageId>Duende.IdentityServer.ConformanceReport</PackageId>
<AssemblyName>Duende.IdentityServer.ConformanceReport</AssemblyName>
<RootNamespace>Duende.IdentityServer.ConformanceReport</RootNamespace>
<Description>IdentityServer adapter for Duende ConformanceReport assessment</Description>
<MinVerTagPrefix>cr-</MinVerTagPrefix>
<MinVerMinimumMajorMinor>0.1</MinVerMinimumMajorMinor>
<PackageReadmePath>README.md</PackageReadmePath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../IdentityServer/IdentityServer.csproj" />
<!-- TODO when conformance package ships, this should be a package reference instead -->
<ProjectReference Include="../../../conformance-report/src/ConformanceReport/ConformanceReport.csproj" />
</ItemGroup>
</Project>

View file

@ -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;
/// <summary>
/// Extension methods for adding conformance to IdentityServer.
/// </summary>
public static class IdentityServerBuilderExtensions
{
/// <summary>
/// Adds conformance assessment to IdentityServer.
/// </summary>
public static IIdentityServerBuilder AddConformanceReport(
this IIdentityServerBuilder builder,
Action<ConformanceReportOptions>? 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<Func<ConformanceReportServerOptions>>(sp =>
{
var options = sp.GetRequiredService<IOptions<IdentityServerOptions>>().Value;
return () => options.ToConformanceReportServerOptions();
});
// Register license info from IdentityServer license
_ = services.AddScoped(sp =>
{
var license = sp.GetService<IdentityServerLicense>();
return license?.ToConformanceReportLicenseInfo() ?? new ConformanceReportLicenseInfo();
});
// Register client store adapter
services.TryAddScoped<IConformanceReportClientStore, IdentityServerClientStore>();
return builder;
}
/// <summary>
/// Converts an IdentityServerLicense to ConformanceReportLicenseInfo.
/// </summary>
internal static ConformanceReportLicenseInfo ToConformanceReportLicenseInfo(this IdentityServerLicense license) =>
new()
{
CompanyName = license.CompanyName,
ContactInfo = license.ContactInfo,
SerialNumber = license.SerialNumber,
Expiration = license.Expiration,
Edition = license.Edition.ToString()
};
}

View file

@ -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;
/// <summary>
/// Adapts IdentityServer's client store to the conformance client store interface.
/// </summary>
#pragma warning disable CA1812 // IdentityServerClientStore is instantiated via DI
internal sealed class IdentityServerClientStore(IClientStore clientStore) : IConformanceReportClientStore
#pragma warning restore CA1812
{
public async Task<IEnumerable<ConformanceReportClient>> GetAllClientsAsync(
CancellationToken ct = default)
{
var clients = new List<ConformanceReportClient>();
await foreach (var client in clientStore.GetAllClientsAsync().WithCancellation(ct))
{
clients.Add(client.ToConformanceReportClient());
}
return clients;
}
}

View file

@ -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<AuthorizationPolicyBuilder>?` | 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**.

View file

@ -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;
/// <summary>
/// Adapts IdentityServerOptions to ConformanceReportServerOptions.
/// </summary>
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
};
}

View file

@ -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;
/// <summary>
/// Integration tests for the IdentityServer adapter.
/// Verifies that an IdentityServer host with conformance report enabled
/// can successfully generate and serve the HTML report.
/// </summary>
public class ConformanceReportIntegrationTests : IAsyncLifetime
{
private WebApplication _app = null!;
private HttpClient _client = null!;
public async ValueTask InitializeAsync()
{
var clients = new List<Client>
{
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("<!DOCTYPE html>");
html.ShouldContain("OAuth 2.1");
html.ShouldContain("FAPI 2.0");
}
}

View file

@ -41,5 +41,6 @@
<ProjectReference Include="..\..\src\Configuration.EntityFramework\IdentityServer.Configuration.EntityFramework.csproj" />
<ProjectReference Include="..\..\src\EntityFramework\IdentityServer.EntityFramework.csproj" />
<ProjectReference Include="..\..\src\IdentityServer\IdentityServer.csproj" />
<ProjectReference Include="..\..\src\IdentityServer.ConformanceReport\IdentityServer.ConformanceReport.csproj" />
</ItemGroup>
</Project>

View file

@ -14,6 +14,13 @@
<Folder Name="/aspnetcore-authentication-jwtbearer/test/">
<Project Path="aspnetcore-authentication-jwtbearer/test/AspNetCore.Authentication.JwtBearer.Tests/AspNetCore.Authentication.JwtBearer.Tests.csproj" />
</Folder>
<Folder Name="/conformance-report/" />
<Folder Name="/conformance-report/src/">
<Project Path="conformance-report/src/ConformanceReport/ConformanceReport.csproj" />
</Folder>
<Folder Name="/conformance-report/test/">
<Project Path="conformance-report/test/ConformanceReport.Tests/ConformanceReport.Tests.csproj" />
</Folder>
<Folder Name="/bff/" />
<Folder Name="/bff/hosts/">
<Project Path="bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj" />
@ -156,6 +163,7 @@
<Project Path="identity-server/src/AspNetIdentity/IdentityServer.AspNetIdentity.csproj" />
<Project Path="identity-server/src/Configuration.EntityFramework/IdentityServer.Configuration.EntityFramework.csproj" />
<Project Path="identity-server/src/Configuration/IdentityServer.Configuration.csproj" />
<Project Path="identity-server/src/IdentityServer.ConformanceReport/IdentityServer.ConformanceReport.csproj" />
<Project Path="identity-server/src/EntityFramework.Storage/IdentityServer.EntityFramework.Storage.csproj" />
<Project Path="identity-server/src/EntityFramework/IdentityServer.EntityFramework.csproj" />
<Project Path="identity-server/src/IdentityServer/IdentityServer.csproj" />