mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Add ConformanceReport — OAuth 2.1 and FAPI 2.0 Security Profile conformance assessment for IdentityServer
This commit is contained in:
parent
dcbf987572
commit
9864b1c6dc
60 changed files with 6805 additions and 0 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
105
conformance-report/README.md
Normal file
105
conformance-report/README.md
Normal 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.
|
||||
11
conformance-report/conformance-report.slnf
Normal file
11
conformance-report/conformance-report.slnf
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
10
conformance-report/src/ConformanceReport/GlobalUsings.cs
Normal file
10
conformance-report/src/ConformanceReport/GlobalUsings.cs
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
40
conformance-report/src/ConformanceReport/Models/Finding.cs
Normal file
40
conformance-report/src/ConformanceReport/Models/Finding.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
12
conformance-report/src/ConformanceReport/Properties/launchSettings.json
vendored
Normal file
12
conformance-report/src/ConformanceReport/Properties/launchSettings.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"profiles": {
|
||||
"ConformanceReport": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:54651;http://localhost:54652"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
|
@ -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",
|
||||
_ => "?"
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
13
conformance-report/src/Directory.Build.props
Normal file
13
conformance-report/src/Directory.Build.props
Normal 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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
conformance-report/test/Directory.Build.props
Normal file
6
conformance-report/test/Directory.Build.props
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project>
|
||||
<Import Project="../../test.props" />
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
131
identity-server/src/IdentityServer.ConformanceReport/README.md
Normal file
131
identity-server/src/IdentityServer.ConformanceReport/README.md
Normal 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**.
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue