diff --git a/Build.ps1 b/Build.ps1 index 1e237cd..ef44e97 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -23,6 +23,7 @@ Process { Exec { & dotnet clean -c Release } Exec { & dotnet build -c Release } Exec { & dotnet test -c Release --no-build --results-directory "$outputDir" --no-restore -l "trx" -l "console;verbosity=detailed" } + Exec { & dotnet build -c Release } # There's a system test that overwrites the previously generated assemblies, so we need to build again Exec { & dotnet pack -c Release --no-build -o "$outputDir" } if (($null -ne $env:NUGET_SOURCE ) -and ($null -ne $env:NUGET_API_KEY)) { diff --git a/README.md b/README.md index 42b606a..706d01c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The **server-side library** includes: * JWT authentication using the [Microsoft.AspNetCore.Authentication.JwtBearer](https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer) library. * Authorization attribute and policy to easily enforce granular scopes on your endpoints. * Authorization attribute and policy to easily enforce classic Workleap permissions (read, write, admin). +* Support of OpenAPI security definition and security requirement generation when using Swashbuckle. * Non-intrusive: default policies must be explicitly used, and the default authentication scheme can be modified. * Support for ASP.NET Core 6.0 and later. @@ -115,12 +116,11 @@ _This client-side library is based on [Duende.AccessTokenManagement](https://git ### Server-side library -The server-side library add the RequireClientCredentials attributes that simplify the use of the client credentials flow in your ASP.NET Core application: +The server-side library add the `RequireClientCredentials` attribute that simplify the use of the client credentials flow in your ASP.NET Core application: - Simply specify the required permissions in the attribute (e.g: `[RequireClientCredentials("read")`] - Support multiple claims types (e.g: `scope`, `scp`, `http://schemas.microsoft.com/identity/claims/scope`) - Support multiple claims format (e.g: `read`, `{Audience}:read`) - Install the package [Workleap.AspNetCore.Authentication.ClientCredentialsGrant](https://www.nuget.org/packages/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/) in your server-side ASP.NET Core application and register the authentication services: ```csharp @@ -146,10 +146,9 @@ For instance, the example above works well with this `appsettings.json`: } ``` -Next protect your endpoints with the `RequireClientCredentials` attribute: +Next, protect your endpoints with the `RequireClientCredentials` attribute: ```csharp - // When using Controlled-Based [HttpGet] [Route("weather")] @@ -158,18 +157,17 @@ public async Task GetWeather() {...} // When using Minimal APIs -app.MapGet("/weather", () => {...}).RequirePermission("read"); +app.MapGet("/weather", () => {...}).RequireClientCredentials("read"); ``` Next, register the authorization services which all the required authorization policies: ```csharp builder.Services - .AddClientCredentialsAuthorization() - ; + .AddClientCredentialsAuthorization(); ``` -Finally, register the authentication and authorization middlewares in your ASP.NET Core app and decorate your endpoints with the `AuthorizeAttribute`: +Finally, register the authentication and authorization middlewares in your ASP.NET Core app. ```csharp var app = builder.Build(); @@ -178,13 +176,54 @@ var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); +// [...] Map your endpoints +``` + +#### OpenAPI integration + +If you are using [Swashbuckle](https://learn.microsoft.com/en-us/aspnet/core/tutorials/web-api-help-pages-using-swagger) to document your API, the `[RequireClientCredentials]` attribute will automatically populate the security definitions and requirements in the OpenAPI specification. For minimal APIs, there is a corresponding `RequireClientCredentials()` method. + +For example: + +```csharp +// Controlled-based approach +[HttpGet] +[Route("weather")] +[RequireClientCredentials("read")] +public async Task GetWeather() +{ /* ... */ } + // Minimal APIs -app.MapGet("/hello-world", () => "Hello World!").RequireAuthorization("my-policy"); +app.MapGet("/weather", () => { /* ... */ }).RequireClientCredentials("read"); +``` -// Controller-style -[Authorize("my-policy")] -[HttpGet("hello-world")] -public IActionResult HelloWorld() => this.Ok("Hello world"); +Will generate this: + +```yaml +paths: + /weather: + get: + summary: 'Required scope: read.' + responses: + '200': + description: OK + '401': + description: Unauthorized + '403': + description: Forbidden + security: + - clientcredentials: + - target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:read # Based on the provided JwtBearerOptions.Audience +components: + securitySchemes: + clientcredentials: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://localhost:9020/oauth2/token # Based on provided ClientCredentials.Authority + scopes: + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f: Request all permissions for specified client ID + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:read: Request permission 'read' for specified client ID ``` diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthenticationBuilderExtensions.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthenticationBuilderExtensions.cs index 411f503..6a66a92 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthenticationBuilderExtensions.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthenticationBuilderExtensions.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; +using Workleap.AspNetCore.Authentication.ClientCredentialsGrant.OpenAPI; // ReSharper disable once CheckNamespace namespace Microsoft.Extensions.DependencyInjection; @@ -19,6 +21,9 @@ public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuil public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuilder builder, Action configureOptions) => builder.AddClientCredentials(ClientCredentialsDefaults.AuthenticationScheme, configureOptions); + /// + /// Adds the Client Credentials authentication scheme and register Swagger security definition and requirement generation. + /// public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuilder builder, string authScheme, Action configureOptions) { ArgumentNullException.ThrowIfNull(builder); @@ -43,6 +48,19 @@ public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuil builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton>(new ClientCredentialsPostConfigureOptions(authScheme))); } + builder.Services.PostConfigure(options => + { + if (options.OperationFilterDescriptors.All(x => x.Type != typeof(SecurityRequirementOperationFilter))) + { + options.OperationFilter(); + } + + if (options.DocumentFilterDescriptors.All(x => x.Type != typeof(SecurityDefinitionDocumentFilter))) + { + options.DocumentFilter(); + } + }); + return builder; } diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthorizationExtensions.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthorizationExtensions.cs index 058aefc..0ca26cb 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthorizationExtensions.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/AuthorizationExtensions.cs @@ -16,6 +16,9 @@ public static class AuthorizationExtensions [ClientCredentialsScope.Admin] = "admin", }; + /// + /// Register the client credentials authorization policies. + /// public static IServiceCollection AddClientCredentialsAuthorization(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); @@ -48,7 +51,7 @@ public static IServiceCollection AddClientCredentialsAuthorization(this IService .RequireClaim("scope", $"{jwtOptions.Audience}:{ScopeClaimMapping[ClientCredentialsScope.Admin]}")); authorizationOptions.AddPolicy( - ClientCredentialsDefaults.AuthorizationRequirePermissionsPolicy, + ClientCredentialsDefaults.RequireClientCredentialsPolicyName, policy => policy .AddAuthenticationSchemes(ClientCredentialsDefaults.AuthenticationScheme) .RequireAuthenticatedUser() diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsAuthorizeAttribute.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsAuthorizeAttribute.cs index d32d1ee..5f24c04 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsAuthorizeAttribute.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsAuthorizeAttribute.cs @@ -17,7 +17,7 @@ public ClientCredentialsAuthorizeAttribute(ClientCredentialsScope scope) { if (!this._policyScopeMapping.TryGetValue(scope, out var policy)) { - throw new ArgumentException($"${scope} is not an valid scope value"); + throw new ArgumentException($"'{scope}' is not an valid scope value"); } this.Scope = scope; diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsDefaults.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsDefaults.cs index f00aea6..f45393b 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsDefaults.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/ClientCredentialsDefaults.cs @@ -6,11 +6,13 @@ public static class ClientCredentialsDefaults internal const string AuthenticationType = "ClientCredentials"; - internal const string AuthorizationRequirePermissionsPolicy = "ClientCredentialRequirePermissions"; + internal const string RequireClientCredentialsPolicyName = "ClientCredentialsPolicy"; internal const string AuthorizationReadPolicy = "ClientCredentialsRead"; internal const string AuthorizationWritePolicy = "ClientCredentialsWrite"; internal const string AuthorizationAdminPolicy = "ClientCredentialsAdmin"; + + internal const string OpenApiSecurityDefinitionId = "clientcredentials"; } \ No newline at end of file diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityDefinitionDocumentFilter.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityDefinitionDocumentFilter.cs new file mode 100644 index 0000000..010f718 --- /dev/null +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityDefinitionDocumentFilter.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant.OpenAPI; + +/// +/// Extract all permissions defined on this API based on the attributes and set it as security definition in OpenAPI. +/// +internal sealed class SecurityDefinitionDocumentFilter(IOptionsMonitor jwtOptionsMonitor) : IDocumentFilter +{ + private readonly JwtBearerOptions _jwtOptions = jwtOptionsMonitor.Get(ClientCredentialsDefaults.AuthenticationScheme); + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var apiPermissions = context.ApiDescriptions.SelectMany(SwaggerUtils.GetRequiredPermissions).ToHashSet(StringComparer.Ordinal); + + swaggerDoc.Components.SecuritySchemes.Add( + ClientCredentialsDefaults.OpenApiSecurityDefinitionId, + new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = this.GetTokenUrl(), + Scopes = this.ExtractScopes(apiPermissions), + }, + }, + }); + } + + private Uri GetTokenUrl() + { + // Authority has already been validated as an absolute URL + var authority = this._jwtOptions.Authority!.TrimEnd('/'); + return new Uri($"{authority}/oauth2/token", UriKind.Absolute); + } + + private Dictionary ExtractScopes(IEnumerable permissions) + { + // Audience has already been validated as non-empty + var audience = this._jwtOptions.Audience!; + + var scopes = new Dictionary + { + [SwaggerUtils.GetScopeForAnyPermission(audience)] = "Request all permissions for specified client ID", + }; + + foreach (var permission in permissions) + { + scopes[SwaggerUtils.FormatScopeForSpecificPermission(audience, permission)] = $"Request permission '{permission}' for specified client ID"; + } + + return scopes; + } +} \ No newline at end of file diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityRequirementOperationFilter.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityRequirementOperationFilter.cs new file mode 100644 index 0000000..67ff428 --- /dev/null +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SecurityRequirementOperationFilter.cs @@ -0,0 +1,93 @@ +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant.OpenAPI; + +/// +/// Add client credential security requirement for each endpoints in OpenAPI based on the attributes. +/// +internal sealed class SecurityRequirementOperationFilter(IOptionsMonitor jwtOptionsMonitor) : IOperationFilter +{ + private readonly JwtBearerOptions _jwtOptions = jwtOptionsMonitor.Get(ClientCredentialsDefaults.AuthenticationScheme); + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var attributes = SwaggerUtils.GetRequiredPermissions(context.ApiDescription).ToHashSet(StringComparer.Ordinal); + if (attributes.Count == 0) + { + return; + } + + // Method need to be idempotent since minimal api are preserving the state + AddAuthenticationAndAuthorizationErrorResponse(operation); + this.AddOperationSecurityReference(operation, attributes); + AppendScopeToOperationSummary(operation, attributes); + } + + private static void AddAuthenticationAndAuthorizationErrorResponse(OpenApiOperation operation) + { + operation.Responses.TryAdd(StatusCodes.Status401Unauthorized.ToString(CultureInfo.InvariantCulture), new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(StatusCodes.Status401Unauthorized) }); + operation.Responses.TryAdd(StatusCodes.Status403Forbidden.ToString(CultureInfo.InvariantCulture), new OpenApiResponse { Description = ReasonPhrases.GetReasonPhrase(StatusCodes.Status403Forbidden) }); + } + + private void AddOperationSecurityReference(OpenApiOperation operation, HashSet permissions) + { + var isAlreadyReferencingSecurityDefinition = operation.Security.Any(requirement => requirement.Keys.Any(key => key.Reference?.Id == ClientCredentialsDefaults.OpenApiSecurityDefinitionId)); + if (isAlreadyReferencingSecurityDefinition) + { + return; + } + + var securityScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = ClientCredentialsDefaults.OpenApiSecurityDefinitionId, + }, + }; + + operation.Security.Add(new OpenApiSecurityRequirement + { + [securityScheme] = this.ExtractScopes(permissions).ToList(), + }); + } + + private static void AppendScopeToOperationSummary(OpenApiOperation operation, HashSet scopes) + { + var requireScopeSummary = new StringBuilder(); + requireScopeSummary.Append(scopes.Count == 1 ? "Required permission: " : "Required permissions: "); + requireScopeSummary.Append(string.Join(", ", scopes)); + requireScopeSummary.Append('.'); + + var isRequireScopeSummaryPresent = operation.Summary?.Contains(requireScopeSummary.ToString()) ?? false; + if (isRequireScopeSummaryPresent) + { + return; + } + + var summary = new StringBuilder(operation.Summary?.TrimEnd('.')); + if (summary.Length > 0) + { + summary.Append(". "); + } + + summary.Append(requireScopeSummary); + + operation.Summary = summary.ToString(); + } + + private IEnumerable ExtractScopes(HashSet permissions) + { + foreach (var permission in permissions) + { + yield return SwaggerUtils.FormatScopeForSpecificPermission(this._jwtOptions.Audience!, permission); + } + } +} \ No newline at end of file diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SwaggerUtils.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SwaggerUtils.cs new file mode 100644 index 0000000..2d7a6ed --- /dev/null +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/OpenAPI/SwaggerUtils.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant.OpenAPI; + +internal static class SwaggerUtils +{ + public static IEnumerable GetRequiredPermissions(ApiDescription apiDescription) + { + var attributes = new List(); + + if (apiDescription.TryGetMethodInfo(out var methodInfo)) + { + var isAnonymousEndpoint = methodInfo.GetCustomAttributes(inherit: true).Any(); + if (isAnonymousEndpoint) + { + return []; + } + + // Controllers - Attributes on the action method (empty for minimal APIs) + attributes.AddRange(methodInfo.GetCustomAttributes(inherit: true)); + } + + // Minimal APIs endpoint metadata (empty for controller actions) + attributes.AddRange(apiDescription.ActionDescriptor.EndpointMetadata.OfType()); + + return attributes.Select(x => x.RequiredPermission); + } + + // It assumes the identity provider is supporting the target-entity scope format + public static string FormatScopeForSpecificPermission(string audience, string permission) + { + return $"target-entity:{audience}:{permission}"; + } + + public static string GetScopeForAnyPermission(string audience) + { + return $"target-entity:{audience}"; + } +} \ No newline at end of file diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/PublicAPI.Shipped.txt b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/PublicAPI.Shipped.txt index 0a492d5..6a05751 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/PublicAPI.Shipped.txt +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/PublicAPI.Shipped.txt @@ -11,11 +11,12 @@ Microsoft.AspNetCore.Authorization.ClientCredentialsScope.Write = 1 -> Microsoft Microsoft.Extensions.DependencyInjection.AuthenticationBuilderExtensions Microsoft.Extensions.DependencyInjection.AuthorizationExtensions Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsAttribute +Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsAttribute.RequiredPermission.get -> string! Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsAttribute.RequireClientCredentialsAttribute(Microsoft.AspNetCore.Authorization.ClientCredentialsScope scope) -> void -Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsAttribute.RequireClientCredentialsAttribute(string! requiredPermission, params string![]! additionalRequiredPermissions) -> void +Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsAttribute.RequireClientCredentialsAttribute(string! requiredPermission) -> void Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsExtensions static Microsoft.Extensions.DependencyInjection.AuthenticationBuilderExtensions.AddClientCredentials(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! static Microsoft.Extensions.DependencyInjection.AuthenticationBuilderExtensions.AddClientCredentials(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authScheme, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! static Microsoft.Extensions.DependencyInjection.AuthenticationBuilderExtensions.AddClientCredentials(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! static Microsoft.Extensions.DependencyInjection.AuthorizationExtensions.AddClientCredentialsAuthorization(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsExtensions.RequirePermission(this TBuilder endpointConventionBuilder, string! requiredPermission, params string![]! additionalRequiredPermissions) -> TBuilder +static Workleap.AspNetCore.Authentication.ClientCredentialsGrant.RequireClientCredentialsExtensions.RequireClientCredentials(this TBuilder endpointConventionBuilder, string! requiredPermission) -> TBuilder diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsAttribute.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsAttribute.cs index ef83c7d..51bee30 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsAttribute.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsAttribute.cs @@ -4,7 +4,7 @@ namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant; -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public sealed class RequireClientCredentialsAttribute : AuthorizeAttribute { private static readonly Dictionary EnumScopeNameMapping = new() @@ -38,20 +38,19 @@ public RequireClientCredentialsAttribute(ClientCredentialsScope scope) /// - It will check in those claims type: scope, scp or http://schemas.microsoft.com/identity/claims/scope
/// - It will accept those value format: `read`, `{Audience}:read` /// - /// Permissions accepted. The users only needs to have one of those permissions. + /// The required permission expected to be in the scope claims. /// /// /// [RequireClientCredentials("invoices.read")] /// /// - [SuppressMessage("Microsoft.Design", "CA1019:DefineAccessorsForAttributeArguments", Justification = "The arguments are transformed.")] - public RequireClientCredentialsAttribute(string requiredPermission, params string[] additionalRequiredPermissions) + public RequireClientCredentialsAttribute(string requiredPermission) { - this.Policy = ClientCredentialsDefaults.AuthorizationRequirePermissionsPolicy; - this.RequiredPermissions = [requiredPermission, ..additionalRequiredPermissions]; + this.Policy = ClientCredentialsDefaults.RequireClientCredentialsPolicyName; + this.RequiredPermission = requiredPermission ?? throw new ArgumentNullException(nameof(requiredPermission)); } - internal HashSet RequiredPermissions { get; } + public string RequiredPermission { get; } } public static class RequireClientCredentialsExtensions @@ -61,19 +60,19 @@ public static class RequireClientCredentialsExtensions /// - It will check in those claims type: scope, scp or http://schemas.microsoft.com/identity/claims/scope
/// - It will accept those value format: `read`, `{Audience}:read` /// - /// Permissions accepted. The users only needs to have one of those permissions. /// - /// Used in Minimal API. + /// Used in minimal APIs. /// /// + /// The required permission expected to be in the claims. /// - /// app.MapGet("/weather", () => {...}).RequirePermission("read"); + /// app.MapGet("/weather", () => { /* ... */ }).RequireClientCredentials("read"); /// /// - public static TBuilder RequirePermission( - this TBuilder endpointConventionBuilder, string requiredPermission, params string[] additionalRequiredPermissions) + public static TBuilder RequireClientCredentials( + this TBuilder endpointConventionBuilder, string requiredPermission) where TBuilder : IEndpointConventionBuilder { - return endpointConventionBuilder.WithMetadata(new RequireClientCredentialsAttribute(requiredPermission, additionalRequiredPermissions)); + return endpointConventionBuilder.WithMetadata(new RequireClientCredentialsAttribute(requiredPermission)); } } diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsRequirementHandler.cs b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsRequirementHandler.cs index 424e55f..8bb8512 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsRequirementHandler.cs +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/RequireClientCredentialsRequirementHandler.cs @@ -37,9 +37,8 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte return Task.CompletedTask; } - var hasRequiredPermissions = HasOneOfScope(context.User, requiredScopes); - - if (hasRequiredPermissions) + var hasRequiredScope = HasRequiredScope(context.User, requiredScopes); + if (hasRequiredScope) { context.Succeed(requirement); } @@ -47,7 +46,7 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte return Task.CompletedTask; } - private bool TryGetRequiredScopes(AuthorizationHandlerContext context, [NotNullWhen(true)] out HashSet? requiredScopes) + private bool TryGetRequiredScopes(AuthorizationHandlerContext context, [NotNullWhen(true)] out string[]? requiredScopes) { requiredScopes = null; @@ -58,14 +57,13 @@ private bool TryGetRequiredScopes(AuthorizationHandlerContext context, [NotNullW _ => null, }; - var requiredPermissions = endpoint?.Metadata.GetMetadata()?.RequiredPermissions; - - if (requiredPermissions == null) + var requiredPermission = endpoint?.Metadata.GetMetadata()?.RequiredPermission; + if (requiredPermission == null) { return false; } - requiredScopes = requiredPermissions.SelectMany(this.FormatScopes).ToHashSet(StringComparer.Ordinal); + requiredScopes = this.FormatScopes(requiredPermission); return true; } @@ -74,10 +72,10 @@ private string[] FormatScopes(string requiredPermission) return [requiredPermission, $"{this._jwtOptions.Audience}:{requiredPermission}"]; } - private static bool HasOneOfScope(ClaimsPrincipal claimsPrincipal, HashSet requiredScopes) + private static bool HasRequiredScope(ClaimsPrincipal claimsPrincipal, string[] requiredScopes) { return claimsPrincipal.Claims - .Where(claim => ScopeClaimTypes.Contains(claim.Type)) - .Any(claim => requiredScopes.Contains(claim.Value)); + .Where(x => ScopeClaimTypes.Contains(x.Type)) + .Any(x => requiredScopes.Contains(x.Value)); } } \ No newline at end of file diff --git a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/Workleap.AspNetCore.Authentication.ClientCredentialsGrant.csproj b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/Workleap.AspNetCore.Authentication.ClientCredentialsGrant.csproj index b98050b..0f9f9b8 100644 --- a/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/Workleap.AspNetCore.Authentication.ClientCredentialsGrant.csproj +++ b/src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/Workleap.AspNetCore.Authentication.ClientCredentialsGrant.csproj @@ -9,11 +9,16 @@ ../Workleap.Authentication.ClientCredentialsGrant.snk Server-side implementation of authenticated machine-to-machine communication using access token for ASP.NET Core. - + + + false + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/AuthorizationExtensionsTest.cs b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/AuthorizationExtensionsTest.cs index b166876..35aebc2 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/AuthorizationExtensionsTest.cs +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/AuthorizationExtensionsTest.cs @@ -51,8 +51,8 @@ public async Task GivenIServiceCollection_WhenAddClientCredentialsAuthorization_ var adminPolicy = authorizationValues.GetPolicy(ClientCredentialsDefaults.AuthorizationAdminPolicy); ValidateClassicPolicy(adminPolicy, ClientCredentialsScope.Admin); - var requirePermissionPolicy = authorizationValues.GetPolicy(ClientCredentialsDefaults.AuthorizationRequirePermissionsPolicy); - ValidateRequirePermissionPolicy(requirePermissionPolicy); + var requireClientCredentialsPolicy = authorizationValues.GetPolicy(ClientCredentialsDefaults.RequireClientCredentialsPolicyName); + ValidateRequireClientCredentialsPolicy(requireClientCredentialsPolicy); } [Fact] @@ -74,19 +74,11 @@ public async Task GivenIServiceCollection_WhenAddClientCredentialsAuthorization_ private static void ValidateClassicPolicy(AuthorizationPolicy? policy, ClientCredentialsScope scope) { Assert.NotNull(policy); - Assert.Collection( - policy.AuthenticationSchemes, - scheme => - { - Assert.Equal(ClientCredentialsDefaults.AuthenticationScheme, scheme); - }); + Assert.Single(policy.AuthenticationSchemes, ClientCredentialsDefaults.AuthenticationScheme); Assert.Equal(2, policy.Requirements.Count); Assert.Collection( policy.Requirements, - x => - { - Assert.Equal(typeof(DenyAnonymousAuthorizationRequirement), x.GetType()); - }, + x => Assert.Equal(typeof(DenyAnonymousAuthorizationRequirement), x.GetType()), x => { Assert.Equal(typeof(ClaimsAuthorizationRequirement), x.GetType()); @@ -100,24 +92,13 @@ private static void ValidateClassicPolicy(AuthorizationPolicy? policy, ClientCre }); } - private static void ValidateRequirePermissionPolicy(AuthorizationPolicy? policy) + private static void ValidateRequireClientCredentialsPolicy(AuthorizationPolicy? policy) { Assert.NotNull(policy); - Assert.Collection( - policy.AuthenticationSchemes, - scheme => - { - Assert.Equal(ClientCredentialsDefaults.AuthenticationScheme, scheme); - }); + Assert.Single(policy.AuthenticationSchemes, ClientCredentialsDefaults.AuthenticationScheme); Assert.Collection( policy.Requirements, - x => - { - Assert.Equal(typeof(DenyAnonymousAuthorizationRequirement), x.GetType()); - }, - x => - { - Assert.Equal(typeof(RequireClientCredentialsRequirement), x.GetType()); - }); + x => Assert.Equal(typeof(DenyAnonymousAuthorizationRequirement), x.GetType()), + x => Assert.Equal(typeof(RequireClientCredentialsRequirement), x.GetType())); } } \ No newline at end of file diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/IntegrationTests.cs b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/IntegrationTests.cs index 1603111..c769522 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/IntegrationTests.cs +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/IntegrationTests.cs @@ -115,8 +115,8 @@ public async Task Real_Client_Server_Communication() webApp.MapGet("/public", () => "This endpoint is public").RequireHost("invoice-app.local"); webApp.MapGet("/read-invoices", () => "This protected endpoint is for reading invoices").RequireAuthorization(ClientCredentialsDefaults.AuthorizationReadPolicy).RequireHost("invoice-app.local"); webApp.MapGet("/pay-invoices", () => "This protected endpoint is for paying invoices").RequireAuthorization(ClientCredentialsDefaults.AuthorizationWritePolicy).RequireHost("invoice-app.local"); - webApp.MapGet("/read-invoices-granular", () => "This protected endpoint is for reading invoices").RequirePermission("read").RequireHost("invoice-app.local"); - webApp.MapGet("/pay-invoices-granular", () => "This protected endpoint is for paying invoices").RequirePermission("pay").RequireHost("invoice-app.local"); + webApp.MapGet("/read-invoices-granular", () => "This protected endpoint is for reading invoices").RequireClientCredentials("read").RequireHost("invoice-app.local"); + webApp.MapGet("/pay-invoices-granular", () => "This protected endpoint is for paying invoices").RequireClientCredentials("pay").RequireHost("invoice-app.local"); using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/OpenAPI/OpenApiSecurityDescriptionTests.cs b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/OpenAPI/OpenApiSecurityDescriptionTests.cs new file mode 100644 index 0000000..45738d0 --- /dev/null +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/OpenAPI/OpenApiSecurityDescriptionTests.cs @@ -0,0 +1,49 @@ +using CliWrap; +using Meziantou.Framework; + +namespace Workleap.Authentication.ClientCredentialsGrant.Tests.OpenAPI; + +public class OpenApiSecurityDescriptionTests +{ + [Fact] + public async Task Given_API_With_Client_Credentials_Attribute_When_Generating_OpenAPI_Then_Equal_Expected_Document() + { + var solutionPath = GetSolutionPath(); + + var testsFolder = Path.Combine(solutionPath, "tests"); + var projectFolder = Path.Combine(testsFolder, "WebApi.OpenAPI.SystemTest"); + var generatedFilePath = Path.Combine(projectFolder, "openapi-v1.yaml"); + var expectedFilePath = Path.Combine(testsFolder, "expected-openapi-document.yaml"); + + // Compile the project + var result = await Cli.Wrap("dotnet") + .WithWorkingDirectory(projectFolder) + .WithValidation(CommandResultValidation.None) + .WithArguments(["build", "--no-incremental"]) + .ExecuteAsync(); + + // Check if the build was successful + Assert.Equal(0, result.ExitCode); + + // Compare the generated file with the expected file + var expectedFileContent = await File.ReadAllTextAsync(expectedFilePath); + var generatedFileContent = await File.ReadAllTextAsync(generatedFilePath); + + Assert.Equal(expectedFileContent, generatedFileContent, ignoreLineEndingDifferences: true); + } + + private static string GetSolutionPath() + { + return GetGitRoot() / "src"; + } + + private static FullPath GetGitRoot() + { + if (FullPath.CurrentDirectory().TryFindFirstAncestorOrSelf(current => Directory.Exists(current / ".git"), out var root)) + { + return root; + } + + throw new InvalidOperationException("git root folder not found"); + } +} \ No newline at end of file diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsAttributeTests.cs b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsAttributeTests.cs index b161061..3a8d626 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsAttributeTests.cs +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsAttributeTests.cs @@ -11,8 +11,7 @@ public class RequireClientCredentialsAttributeTests public void GivenAllPossibleClassicScopes_WhenCreate_ThenExpectedPermission(ClientCredentialsScope scope, string expectedPermission) { var attribute = new RequireClientCredentialsAttribute(scope); - var permission = Assert.Single(attribute.RequiredPermissions); - Assert.Equal(expectedPermission, permission); + Assert.Equal(expectedPermission, attribute.RequiredPermission); } [Fact] @@ -27,25 +26,16 @@ public void GivenSinglePermission_WhenCreate_ThenSamePermission() { var expectedPermission = "cocktail.drink"; var attribute = new RequireClientCredentialsAttribute(expectedPermission); - var permission = Assert.Single(attribute.RequiredPermissions); - Assert.Equal(expectedPermission, permission); - } - - [Fact] - public void GivenMultiplePermission_WhenCreate_ThenSamePermissions() - { - var expectedPermissions = new[] { "cocktail.drink", "cocktail.make", "cocktail.buy" }; - var attribute = new RequireClientCredentialsAttribute(expectedPermissions.First(), expectedPermissions.Skip(1).ToArray()); - Assert.True(attribute.RequiredPermissions.SetEquals(expectedPermissions)); + Assert.Equal(expectedPermission, attribute.RequiredPermission); } private sealed class ClientCredentialsScopeData : IEnumerable { public IEnumerator GetEnumerator() { - yield return new object[] { ClientCredentialsScope.Read, "read" }; - yield return new object[] { ClientCredentialsScope.Write, "write" }; - yield return new object[] { ClientCredentialsScope.Admin, "admin" }; + yield return [ClientCredentialsScope.Read, "read"]; + yield return [ClientCredentialsScope.Write, "write"]; + yield return [ClientCredentialsScope.Admin, "admin"]; } IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsRequirementHandlerTests.cs b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsRequirementHandlerTests.cs index 6496167..bed3881 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsRequirementHandlerTests.cs +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/RequireClientCredentialsRequirementHandlerTests.cs @@ -23,14 +23,11 @@ public async Task GivenUserHaveTheRequiredScopesInOneOfClaimType_WhenHandleRequi new(scopeClaimType, "requiredPermission"), new(scopeClaimType, "otherPermission"), }; - - var requiredPermissions = new List - { - "requiredPermission", - }; - var context = this.ConfigureHandlerContext(userClaims, requiredPermissions); - var handler = this.ConfigureHandler(new JwtBearerOptions()); + var requiredPermission = "requiredPermission"; + + var context = ConfigureHandlerContext(userClaims, requiredPermission); + var handler = ConfigureHandler(new JwtBearerOptions()); // When await handler.HandleAsync(context); @@ -53,14 +50,10 @@ public async Task GivenUserHaveOneOfTheRequiredScopes_WhenHandleRequirement_Then new("scope", "otherPermission"), }; - var requiredPermissions = new List - { - "requiredPermission", - "alternativeRequiredPermission", - }; + var requiredPermission = "requiredPermission"; - var context = this.ConfigureHandlerContext(userClaims, requiredPermissions); - var handler = this.ConfigureHandler(new JwtBearerOptions() + var context = ConfigureHandlerContext(userClaims, requiredPermission); + var handler = ConfigureHandler(new JwtBearerOptions { Audience = expectedAudience, }); @@ -81,14 +74,11 @@ public async Task GivenUserDoNotHaveTheRequiredScopes_WhenHandleRequirement_Then new("scope", "requiredPermission1"), new("scope", "otherPermission"), }; - - var requiredPermissions = new List - { - "randomPermission", - }; - var context = this.ConfigureHandlerContext(userClaims, requiredPermissions); - var handler = this.ConfigureHandler(new JwtBearerOptions()); + var requiredPermission = "randomPermission"; + + var context = ConfigureHandlerContext(userClaims, "randomPermission"); + var handler = ConfigureHandler(new JwtBearerOptions()); // When await handler.HandleAsync(context); @@ -97,7 +87,7 @@ public async Task GivenUserDoNotHaveTheRequiredScopes_WhenHandleRequirement_Then Assert.False(context.HasSucceeded); } - private RequireClientCredentialsRequirementHandler ConfigureHandler(JwtBearerOptions jwtOptions) + private static RequireClientCredentialsRequirementHandler ConfigureHandler(JwtBearerOptions jwtOptions) { var jwtOptionsMonitor = A.Fake>(); A.CallTo(() => jwtOptionsMonitor.Get(ClientCredentialsDefaults.AuthenticationScheme)).Returns(jwtOptions); @@ -105,15 +95,15 @@ private RequireClientCredentialsRequirementHandler ConfigureHandler(JwtBearerOpt return new RequireClientCredentialsRequirementHandler(jwtOptionsMonitor); } - private AuthorizationHandlerContext ConfigureHandlerContext(List claims, List requiredPermissions) + private static AuthorizationHandlerContext ConfigureHandlerContext(List claims, string requiredPermission) { var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); - var requiredPermissionsAttribute = new RequireClientCredentialsAttribute(requiredPermissions.First(), requiredPermissions.Skip(1).ToArray()); + var requireClientCredentialsAttribute = new RequireClientCredentialsAttribute(requiredPermission); var httpContext = new DefaultHttpContext(); httpContext.Features.Set(new EndpointFeature { - Endpoint = new Endpoint(default, new EndpointMetadataCollection(requiredPermissionsAttribute), default), + Endpoint = new Endpoint(requestDelegate: default, new EndpointMetadataCollection(requireClientCredentialsAttribute), displayName: default), }); return new AuthorizationHandlerContext(new[] { new RequireClientCredentialsRequirement() }, user, httpContext); diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/Workleap.Authentication.ClientCredentialsGrant.Tests.csproj b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/Workleap.Authentication.ClientCredentialsGrant.Tests.csproj index 562fb46..40dbbef 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.Tests/Workleap.Authentication.ClientCredentialsGrant.Tests.csproj +++ b/src/Workleap.Authentication.ClientCredentialsGrant.Tests/Workleap.Authentication.ClientCredentialsGrant.Tests.csproj @@ -14,8 +14,10 @@ + + diff --git a/src/Workleap.Authentication.ClientCredentialsGrant.sln b/src/Workleap.Authentication.ClientCredentialsGrant.sln index 7254823..aa66a89 100644 --- a/src/Workleap.Authentication.ClientCredentialsGrant.sln +++ b/src/Workleap.Authentication.ClientCredentialsGrant.sln @@ -16,6 +16,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Workleap.Authentication.Cli EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Workleap.AspNetCore.Authentication.ClientCredentialsGrant", "Workleap.AspNetCore.Authentication.ClientCredentialsGrant\Workleap.AspNetCore.Authentication.ClientCredentialsGrant.csproj", "{67B0A8F4-3292-4F39-923F-35814ADF4FA5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi.OpenAPI.SystemTest", "tests\WebApi.OpenAPI.SystemTest\WebApi.OpenAPI.SystemTest.csproj", "{9667A3D7-4927-4CA1-B645-14898A687E30}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{75AE09A3-B3BD-4909-ADE7-CCF55EF71002}" + ProjectSection(SolutionItems) = preProject + tests\expected-openapi-document.yaml = tests\expected-openapi-document.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,5 +44,12 @@ Global {67B0A8F4-3292-4F39-923F-35814ADF4FA5}.Debug|Any CPU.Build.0 = Debug|Any CPU {67B0A8F4-3292-4F39-923F-35814ADF4FA5}.Release|Any CPU.ActiveCfg = Release|Any CPU {67B0A8F4-3292-4F39-923F-35814ADF4FA5}.Release|Any CPU.Build.0 = Release|Any CPU + {9667A3D7-4927-4CA1-B645-14898A687E30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9667A3D7-4927-4CA1-B645-14898A687E30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9667A3D7-4927-4CA1-B645-14898A687E30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9667A3D7-4927-4CA1-B645-14898A687E30}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9667A3D7-4927-4CA1-B645-14898A687E30} = {75AE09A3-B3BD-4909-ADE7-CCF55EF71002} EndGlobalSection EndGlobal diff --git a/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialMinimalApis.cs b/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialMinimalApis.cs new file mode 100644 index 0000000..78c141e --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialMinimalApis.cs @@ -0,0 +1,15 @@ +using Workleap.AspNetCore.Authentication.ClientCredentialsGrant; + +namespace WebApi.OpenAPI.SystemTest.Endpoints; + +public static class ClientCredentialMinimalApis +{ + public static void MapMinimalEndpoint(this WebApplication app) + { + app.MapGet("/minimal-api", () => "Hello World") + .WithSummary("This minimal API should require the cocktail.make permission.") + .RequireClientCredentials("cocktail.make") + .WithTags("ClientCredentials") + .WithOpenApi(); + } +} \ No newline at end of file diff --git a/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialsController.cs b/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialsController.cs new file mode 100644 index 0000000..0f1fa84 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/Endpoints/ClientCredentialsController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using Workleap.AspNetCore.Authentication.ClientCredentialsGrant; + +namespace WebApi.OpenAPI.SystemTest.Endpoints; + +[ApiController] +public class ClientCredentialsController : ControllerBase +{ + [HttpPost] + [Route("/controller-allow-anonymous-override")] + [SwaggerOperation(Summary = "This controller method decorated with both AllowAnonymous and RequireClientCredentials should not require any permissions.")] + [RequireClientCredentials("cocktail.drink")] + [AllowAnonymous] + public IActionResult SeeCocktail(int id) + { + return this.Ok("Hello World!"); + } + + [HttpPost] + [Route("/controller-requires-permission")] + [SwaggerOperation(Summary = "This controller method should require the cocktail.buy permission.")] + [RequireClientCredentials("cocktail.buy")] + public IActionResult BuyCocktail(int id) + { + return this.Ok("Hello World!"); + } +} \ No newline at end of file diff --git a/src/tests/WebApi.OpenAPI.SystemTest/Program.cs b/src/tests/WebApi.OpenAPI.SystemTest/Program.cs new file mode 100644 index 0000000..8a5ad21 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/Program.cs @@ -0,0 +1,22 @@ +using WebApi.OpenAPI.SystemTest; +using WebApi.OpenAPI.SystemTest.Endpoints; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +builder.Services.AddSwagger(); + +// Setup specific to Client Credentials Grant +builder.Services.AddAuthentication().AddClientCredentials(); +builder.Services.AddClientCredentialsAuthorization(); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapMinimalEndpoint(); +app.MapControllers(); + +app.Run(); diff --git a/src/tests/WebApi.OpenAPI.SystemTest/Properties/launchSettings.json b/src/tests/WebApi.OpenAPI.SystemTest/Properties/launchSettings.json new file mode 100644 index 0000000..5e339b3 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7219;http://localhost:5241", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/tests/WebApi.OpenAPI.SystemTest/SwaggerConfigurationExtensions.cs b/src/tests/WebApi.OpenAPI.SystemTest/SwaggerConfigurationExtensions.cs new file mode 100644 index 0000000..1613932 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/SwaggerConfigurationExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.OpenApi.Models; + +namespace WebApi.OpenAPI.SystemTest; + +public static class SwaggerConfigurationExtensions +{ + public static IServiceCollection AddSwagger(this IServiceCollection services) + { + // Required to detect Minimal Api Endpoints + services.AddEndpointsApiExplorer(); + + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); + options.EnableAnnotations(); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/tests/WebApi.OpenAPI.SystemTest/WebApi.OpenAPI.SystemTest.csproj b/src/tests/WebApi.OpenAPI.SystemTest/WebApi.OpenAPI.SystemTest.csproj new file mode 100644 index 0000000..d476dde --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/WebApi.OpenAPI.SystemTest.csproj @@ -0,0 +1,31 @@ + + + net8.0 + false + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + ./custom.spectral.yaml + + + + + PreserveNewest + + + + + + + diff --git a/src/tests/WebApi.OpenAPI.SystemTest/appsettings.json b/src/tests/WebApi.OpenAPI.SystemTest/appsettings.json new file mode 100644 index 0000000..0a31eda --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Authentication": { + "Schemes": { + "ClientCredentials": { + "Authority": "https://localhost:9020", + "Audience": "b108bbc9-538e-403b-9faf-e5cd874eb17f", + "MetadataAddress": "https://localhost:9020/.well-known/openid-configuration" + } + } + } +} diff --git a/src/tests/WebApi.OpenAPI.SystemTest/custom.spectral.yaml b/src/tests/WebApi.OpenAPI.SystemTest/custom.spectral.yaml new file mode 100644 index 0000000..9952998 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/custom.spectral.yaml @@ -0,0 +1 @@ +extends: [[spectral:oas, off]] \ No newline at end of file diff --git a/src/tests/WebApi.OpenAPI.SystemTest/openapi-v1.yaml b/src/tests/WebApi.OpenAPI.SystemTest/openapi-v1.yaml new file mode 100644 index 0000000..f7bd2a6 --- /dev/null +++ b/src/tests/WebApi.OpenAPI.SystemTest/openapi-v1.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: Test API + version: v1 +paths: + /minimal-api: + get: + tags: + - ClientCredentials + summary: 'This minimal API should require the cocktail.make permission. Required permission: cocktail.make.' + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized + '403': + description: Forbidden + security: + - clientcredentials: + - target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.make + /controller-allow-anonymous-override: + post: + tags: + - ClientCredentials + summary: This controller method decorated with both AllowAnonymous and RequireClientCredentials should not require any permissions. + parameters: + - name: id + in: query + schema: + type: integer + format: int32 + responses: + '200': + description: OK + /controller-requires-permission: + post: + tags: + - ClientCredentials + summary: 'This controller method should require the cocktail.buy permission. Required permission: cocktail.buy.' + parameters: + - name: id + in: query + schema: + type: integer + format: int32 + responses: + '200': + description: OK + '401': + description: Unauthorized + '403': + description: Forbidden + security: + - clientcredentials: + - target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.buy +components: + securitySchemes: + clientcredentials: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://localhost:9020/oauth2/token + scopes: + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f: Request all permissions for specified client ID + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.make: Request permission 'cocktail.make' for specified client ID + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.buy: Request permission 'cocktail.buy' for specified client ID \ No newline at end of file diff --git a/src/tests/expected-openapi-document.yaml b/src/tests/expected-openapi-document.yaml new file mode 100644 index 0000000..f7bd2a6 --- /dev/null +++ b/src/tests/expected-openapi-document.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: Test API + version: v1 +paths: + /minimal-api: + get: + tags: + - ClientCredentials + summary: 'This minimal API should require the cocktail.make permission. Required permission: cocktail.make.' + responses: + '200': + description: OK + content: + text/plain: + schema: + type: string + '401': + description: Unauthorized + '403': + description: Forbidden + security: + - clientcredentials: + - target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.make + /controller-allow-anonymous-override: + post: + tags: + - ClientCredentials + summary: This controller method decorated with both AllowAnonymous and RequireClientCredentials should not require any permissions. + parameters: + - name: id + in: query + schema: + type: integer + format: int32 + responses: + '200': + description: OK + /controller-requires-permission: + post: + tags: + - ClientCredentials + summary: 'This controller method should require the cocktail.buy permission. Required permission: cocktail.buy.' + parameters: + - name: id + in: query + schema: + type: integer + format: int32 + responses: + '200': + description: OK + '401': + description: Unauthorized + '403': + description: Forbidden + security: + - clientcredentials: + - target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.buy +components: + securitySchemes: + clientcredentials: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://localhost:9020/oauth2/token + scopes: + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f: Request all permissions for specified client ID + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.make: Request permission 'cocktail.make' for specified client ID + target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:cocktail.buy: Request permission 'cocktail.buy' for specified client ID \ No newline at end of file