Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IDP-873] Generate Openapi Description of Security Requirements #80

Merged
merged 44 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
950a926
POC to support granular permission
Jun 18, 2024
3f13916
Add unit test
Jun 19, 2024
e6dbae3
Cleanup
Jun 19, 2024
ab73911
Update readme
Jun 19, 2024
15c0535
Cleanup PR
Jun 19, 2024
939a553
Fix linting
Jun 20, 2024
cc131f9
Deploy on gsoftdev feed on CI
Jun 20, 2024
5d6be41
Move to shipped
Jun 20, 2024
9dd8831
Use hashset in requirement handler
asimmon Jun 20, 2024
ea422d9
Fix CR
Jun 20, 2024
fc10110
Merge remote-tracking branch 'origin/feature/idp-1027_support_policy_…
Jun 20, 2024
1c35ee7
Update src/Workleap.AspNetCore.Authentication.ClientCredentialsGrant/…
PrincessMadMath Jun 20, 2024
7aa2219
Fix test
Jun 20, 2024
25873fe
wip
Jun 20, 2024
5222c72
CR fix
Jun 20, 2024
05cc418
Merge branch 'refs/heads/feature/idp-1027_support_policy_with_custom_…
Jun 20, 2024
cf5b9cf
wip
Jun 20, 2024
6b6673c
Merge branch 'refs/heads/main' into feature/idp-873_generate_openapi_…
Jun 20, 2024
61335a0
POC to generate OpenAPI document for security
Jun 20, 2024
393c6fc
wip
Jun 21, 2024
ecc1cb4
Fix test
Jun 21, 2024
51872a9
Cleanup
Jun 21, 2024
ff3425b
Make openapi env agnostic
Jun 21, 2024
03e4fcb
Check if test is breaking build
Jun 21, 2024
892f0cf
CR cleanup
Jun 21, 2024
b365f6f
Disable openapi on build + fix reference
Jun 25, 2024
457bb30
UPdate spec
Jun 25, 2024
dfd8730
Use get git root strategy for path
Jun 25, 2024
a67ea4f
Cleanup
Jun 25, 2024
e2386c4
CR cleanup
Jun 25, 2024
a751682
Update readme
Jun 25, 2024
f352e6e
Cleanup
Jun 25, 2024
2e71925
Add missing summary
Jun 25, 2024
79abeca
Support empty authority
Jun 25, 2024
3e4df6d
Now really support null authority
Jun 25, 2024
b697007
Merge branch 'refs/heads/main' into feature/idp-873_generate_openapi_…
Jun 25, 2024
455a1d7
Fix merge
Jun 25, 2024
5fca433
Code review fixes
asimmon Jun 28, 2024
55a3ce1
A single permission is now required
asimmon Jun 28, 2024
e49580b
Update readme
asimmon Jun 28, 2024
15ef2f7
Update README
asimmon Jun 28, 2024
4a35490
Prevent multiple RequireClientCredentials attributes
asimmon Jul 3, 2024
071d4c9
Fix CI
asimmon Jul 3, 2024
2b8d48a
Typo
asimmon Jul 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -165,8 +166,7 @@ Next, register the authorization services which all the required authorization p

```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`:
Expand All @@ -187,6 +187,59 @@ app.MapGet("/hello-world", () => "Hello World!").RequireAuthorization("my-policy
public IActionResult HelloWorld() => this.Ok("Hello world");
```

#### OpenAPI integration

If you are using Swashbuckle to generate your OpenAPI documentation, if you are using the `RequireClientCredentials` attribute it will automatically populate the security definitions and requirements in the OpenAPI document.

For example:

```csharp

// When using Controlled-Based
[HttpGet]
[Route("weather")]
[RequireClientCredentials("read")]
public async Task<IActionResult> GetWeather()
{...}

// When using Minimal APIs
app.MapGet("/weather", () => {...}).RequirePermission("read");
```

Will generate this:

```yaml
paths:
/weather:
get:
summary: 'Required scope: read.'
responses:
'200':
description: OK
'401':
description: Unauthorized
'403':
description: Forbidden
security:
- oauth2-clientcredentials:
- target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:read /* Based on ClientCredentials options: Audience */
components:
securitySchemes:
oauth2-clientcredentials:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://localhost:9020/oauth2/token /* Based on ClientCredentials options: Authority */
scopes:
target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f: Request all permissions for specified client_id
target-entity:b108bbc9-538e-403b-9faf-e5cd874eb17f:Request this permission read for specified client_id: read


```


```csharp


## Building, releasing and versioning

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +21,9 @@ public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuil
public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuilder builder, Action<JwtBearerOptions> configureOptions)
=> builder.AddClientCredentials(ClientCredentialsDefaults.AuthenticationScheme, configureOptions);

/// <summary>
/// Adds the Client Credentials authentication scheme and register Swagger security definition and requirement generation.
/// </summary>
public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuilder builder, string authScheme, Action<JwtBearerOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(builder);
Expand All @@ -43,6 +48,19 @@ public static AuthenticationBuilder AddClientCredentials(this AuthenticationBuil
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>>(new ClientCredentialsPostConfigureOptions(authScheme)));
}

builder.Services.PostConfigure<SwaggerGenOptions>(options =>
{
if (options.OperationFilterDescriptors.All(x => x.Type != typeof(SecurityRequirementOperationFilter)))
{
options.OperationFilter<SecurityRequirementOperationFilter>();
}

if (options.OperationFilterDescriptors.All(x => x.Type != typeof(SecurityDefinitionDocumentFilter)))
{
options.DocumentFilter<SecurityDefinitionDocumentFilter>();
}
});

return builder;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public static class AuthorizationExtensions
[ClientCredentialsScope.Admin] = "admin",
};

/// <summary>
///
/// </summary>
public static IServiceCollection AddClientCredentialsAuthorization(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public static class ClientCredentialsDefaults
internal const string AuthorizationWritePolicy = "ClientCredentialsWrite";

internal const string AuthorizationAdminPolicy = "ClientCredentialsAdmin";

internal static readonly string OpenApiSecurityDefinitionId = $"oauth2-{AuthenticationScheme.ToLowerInvariant()}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant.OpenAPI;

/// <summary>
/// Extract all permissions defined on this API based on the <see cref="RequireClientCredentialsAttribute"/> attributes and set it as security definition in OpenAPI.
/// </summary>
internal sealed class SecurityDefinitionDocumentFilter : IDocumentFilter
{
private readonly JwtBearerOptions _jwtOptions;

public SecurityDefinitionDocumentFilter(IOptionsMonitor<JwtBearerOptions> jwtOptionsMonitor)
{
this._jwtOptions = jwtOptionsMonitor.Get(ClientCredentialsDefaults.AuthenticationScheme);
}

public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var apiPermissions = context.ApiDescriptions.SelectMany(SwaggerUtils.GetRequiredPermissions).Distinct();

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()
{
var authority = this._jwtOptions.Authority?.TrimEnd('/') ?? string.Empty;
return new Uri($"{authority}/oauth2/token");
}

private Dictionary<string, string> ExtractScopes(IEnumerable<string> permissions)
{
var audience = this._jwtOptions.Audience ?? string.Empty;

var scopes = new Dictionary<string, string>
{
{ SwaggerUtils.GetAllScope(audience), "Request all permissions for specified client_id" },
};

foreach (var permission in permissions)
{
scopes[SwaggerUtils.FormatScope(audience, $"Request this permission {permission} for specified client_id")] = permission;
}

return scopes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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;

/// <summary>
/// Add client credential security requirement for each endpoints in OpenAPI based on the <see cref="RequireClientCredentialsAttribute"/> attributes.
/// </summary>
internal sealed class SecurityRequirementOperationFilter : IOperationFilter
{
private readonly JwtBearerOptions _jwtOptions;

public SecurityRequirementOperationFilter(IOptionsMonitor<JwtBearerOptions> jwtOptionsMonitor)
{
this._jwtOptions = jwtOptionsMonitor.Get(ClientCredentialsDefaults.AuthenticationScheme);
}

public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var attributes = SwaggerUtils.GetRequiredPermissions(context.ApiDescription).ToArray();
if (attributes.Length == 0)
{
return;
}

// Method need to be idempotent since minimal api are preserving the state
AddAuthzErrorResponse(operation);
this.AddOperationSecurityReference(operation, attributes);
AppendScopeToOperationSummary(operation, attributes);
}

private static void AddAuthzErrorResponse(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, string[] permissions)
{
if (operation.Security.Any(requirement => requirement.Keys.Any(key => key.Reference?.Id == ClientCredentialsDefaults.OpenApiSecurityDefinitionId)))
{
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, string[] scopes)
{
var requireScopeSummary = new StringBuilder();
requireScopeSummary.Append(scopes.Length == 1 ? "Required scope: " : "Required scopes: ");
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<string> ExtractScopes(string[] permissions)
{
foreach (var permission in permissions)
{
yield return $"target-entity:{this._jwtOptions.Audience}:{permission}";
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string> GetRequiredPermissions(ApiDescription apiDescription)
{
List<RequireClientCredentialsAttribute> attributes = new();

if (apiDescription.TryGetMethodInfo(out var methodInfo))
{
var hasAllowAnonymous = methodInfo.GetCustomAttributes<AllowAnonymousAttribute>(inherit: true).Any();
if (hasAllowAnonymous)
{
return Enumerable.Empty<string>();
}

// Controllers - Attributes on the action method (empty for minimal APIs)
attributes.AddRange(methodInfo.GetCustomAttributes<RequireClientCredentialsAttribute>(inherit: true));
}

// Minimal APIs endpoint metadata (empty for controller actions)
attributes.AddRange(apiDescription.ActionDescriptor.EndpointMetadata.OfType<RequireClientCredentialsAttribute>());

return attributes.SelectMany(attribute => attribute.RequiredPermissions).Distinct();
}

// It assumes the identity provider is supporting the target-entity scope format
public static string FormatScope(string audience, string permission)
{
return $"target-entity:{audience}:{permission}";
}

public static string GetAllScope(string audience)
{
return $"target-entity:{audience}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Workleap.AspNetCore.Authentication.ClientCredentialsGrant;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
[AttributeUsage(AttributeTargets.Method)]
public sealed class RequireClientCredentialsAttribute : AuthorizeAttribute
{
private static readonly Dictionary<ClientCredentialsScope, string> EnumScopeNameMapping = new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
<AssemblyOriginatorKeyFile>../Workleap.Authentication.ClientCredentialsGrant.snk</AssemblyOriginatorKeyFile>
<Description>Server-side implementation of authenticated machine-to-machine communication using access token for ASP.NET Core.</Description>
</PropertyGroup>


<PropertyGroup>
<OpenApiGenerateDocumentsOnBuild>false</OpenApiGenerateDocumentsOnBuild>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.0" Condition=" '$(TargetFramework)' == 'net6.0' " />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.0" Condition=" '$(TargetFramework)' == 'net7.0' " />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" Condition=" '$(TargetFramework)' == 'net8.0' " />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading