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

Feature - add operation filters for Shared Access Key and Certificate authentications in OpenAPI docs #172

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
64 changes: 53 additions & 11 deletions docs/preview/features/openapi/security-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ layout: default

# Adding OAuth security definition to API operations

When an API is secured via OAuth, it is helpful if the Open API documentation makes this clear via a security scheme and the API operations that require authorization automatically inform the consumer that it is possible that a 401 Unauthorized or 403 Forbidden response is returned.
The `OAuthAuthorizeOperationFilter` that is part of this package exposes this functionality.
When an API is secured via OAuth, [Shared Access Key authentication](../../features/security/auth/shared-access-key), [Certificate authentication](../../features/security/auth/certificate), it is helpful if the Open API documentation makes this clear via a security scheme and the API operations that require authorization automatically inform the consumer that it is possible that a 401 Unauthorized or 403 Forbidden response is returned.

These `IOperationFilter`'s that are part of this package exposes this functionality:
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
- [`OAuthAuthorizeOperationFilter`](#oauth)
- [`SharedAccessKeyAuthenticationOperationFilter`](#sharedaccesskey)
- [`CertificateAuthenticationOperationFilter`](#certificate)

## Installation

Expand All @@ -18,21 +22,59 @@ PM > Install-Package Arcus.WebApi.OpenApi.Extensions

## Usage
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved

To indicate that an API is protected by OAuth, you need to add `AuthorizeCheckOperationFilter` as an `OperationFilter` when configuring Swashbuckles Swagger generation:
### OAuth

To indicate that an API is protected by OAuth, you need to add `OAuthAuthorizeOperationFilter` as an `IOperationFilter` when configuring Swashbuckles Swagger generation:

```csharp
services.AddSwaggerGen(setupAction =>
{
setupAction.SwaggerDoc("v1", new Info { Title = "My API v1", Version = "v1" });

setupAction.AddSecurityDefinition("oauth2", new OAuth2Scheme
{
Flow = "implicit",
AuthorizationUrl = $"{authorityUrl}connect/authorize",
Scopes = scopes
});

setupAction.OperationFilter<OAuthAuthorizeOperationFilter>(new object[] { new[] { "myApiScope1", "myApiScope2" } });
});
```

### Shared Access Key

To indicate that an API is protected by [Shared Access Key authentication](../../features/security/auth/shared-access-key), you need to add `SharedAccessKeyAuthenticationOperationFilter` as an `IOperationFilter` when configuring Swashbuckles Swagger generation:

```csharp
services.AddSwaggerGen(setupAction =>
{
setupAction.SwaggerDoc("v1", new Info { Title = "My API v1", Version = "v1" });

setupAction.AddSecurityDefinition("sharedaccesskey", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
});

setupAction.OperationFilter<SharedAccessKeyAuthenticationOperationFilter>(new object[] { new[] { "myApiScope1", "myApiScope2" } });
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
});
```

### Shared Access Key
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved

To indicate that an API is protected by [Certificate authentication](../../features/security/auth/certificate), you need to add `CertificateAuthenticationOperationFilter` as an `IOperationFilter` when configuring Swashbuckles Swagger generation:

```csharp
services.AddSwaggerGen(setupAction =>
{
setupAction.SwaggerDoc("v1", new Info { Title = "My API v1", Version = "v1" });
setupAction.SwaggerDoc("v1", new Info { Title = "My API v1", Version = "v1" });

setupAction.AddSecurityDefinition("oauth2", new OAuth2Scheme
{
Flow = "implicit",
AuthorizationUrl = $"{authorityUrl}connect/authorize",
Scopes = scopes
});
setupAction.AddSecurityDefinition("certificate", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
});

setupAction.OperationFilter<OAuthAuthorizeOperationFilter>(new object[] {new [] {"myApiScope1", "myApiScope2"});
setupAction.OperationFilter<CertificateAuthenticationOperationFilter>(new object[] { new[] { "myApiScope1", "myApiScope2" } });
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.4.1" Condition="'$(TargetFramework)' == 'netcoreapp3.1'" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Arcus.WebApi.Security\Arcus.WebApi.Security.csproj" />
</ItemGroup>

<Target Name="GenerateOpenApiDocuments" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Arcus.WebApi.Security.Authentication.Certificates;
using Arcus.WebApi.Security.Authentication.SharedAccessKey;
using GuardNet;
#if NETCOREAPP3_1
using Microsoft.OpenApi.Models;
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
#endif
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Arcus.WebApi.OpenApi.Extensions
{
/// <summary>
/// A Swashbuckle operation filter that adds certificate security definitions to authorized API operations.
/// </summary>
public class CertificateAuthenticationOperationFilter : IOperationFilter
{
private readonly IEnumerable<string> _scopes;

/// <summary>
/// Initializes a new instance of the <see cref="CertificateAuthenticationOperationFilter"/> class.
/// </summary>
/// <param name="scopes">A list of API scopes that is defined for the API that must be documented.</param>
/// <remarks>It is not possible right now to document the scopes on a fine grained operation-level.</remarks>
/// <exception cref="ArgumentNullException">When the <paramref name="scopes"/> are <c>null</c>.</exception>
/// <exception cref="ArgumentException">When the <paramref name="scopes"/> has any elements that are <c>null</c> or blank.</exception>
public CertificateAuthenticationOperationFilter(IEnumerable<string> scopes)
{
Guard.NotNull(scopes, nameof(scopes), "The sequence of scopes cannot be null");
Guard.For<ArgumentException>(() => scopes.Any(String.IsNullOrWhiteSpace), "The sequence of scopes cannot contain a scope that is null or blank");

_scopes = scopes;
}

/// <summary>
/// Applies the OperationFilter to the API <paramref name="operation"/>.
/// </summary>
/// <param name="operation">The operation instance on which the OperationFilter must be applied.</param>
/// <param name="context">Provides meta-information on the <paramref name="operation"/> instance.</param>
#if NETCOREAPP3_1
public void Apply(OpenApiOperation operation, OperationFilterContext context)
#else
public void Apply(Operation operation, OperationFilterContext context)
#endif
{
bool hasOperationAuthentication =
context.MethodInfo
.GetCustomAttributes(true)
.OfType<CertificateAuthenticationAttribute>()
.Any();

bool hasControllerAuthentication =
context.MethodInfo.DeclaringType != null
&& context.MethodInfo.DeclaringType
.GetCustomAttributes(true)
.OfType<CertificateAuthenticationAttribute>()
.Any();

if (hasOperationAuthentication || hasControllerAuthentication)
{
if (operation.Responses.ContainsKey("401") == false)
{
#if NETCOREAPP3_1
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
#else
operation.Responses.Add("401", new Response { Description = "Unauthorized" });
#endif
}

if (operation.Responses.ContainsKey("403") == false)
{
#if NETCOREAPP3_1
operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
#else
operation.Responses.Add("403", new Response { Description = "Forbidden" });
#endif
}
#if NETCOREAPP3_1
var scheme = new OpenApiSecurityScheme
{
Scheme = "certificate",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hardcoded but must match what was defined as a security requirement, we should make this configurable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made it configurable. Thanks!

Type = SecuritySchemeType.ApiKey
tomkerkhove marked this conversation as resolved.
Show resolved Hide resolved
};

operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
[scheme] = _scopes.ToList()
}
};
#else
operation.Security = new List<IDictionary<string, IEnumerable<string>>>
{
new Dictionary<string, IEnumerable<string>> { ["certificate"] = _scopes }
};
#endif
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Arcus.WebApi.Security.Authentication.SharedAccessKey;
using GuardNet;
#if NETCOREAPP3_1
using Microsoft.OpenApi.Models;
#endif
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Arcus.WebApi.OpenApi.Extensions
{
/// <summary>
/// A Swashbuckle operation filter that adds shared access key security definitions to authorized API operations.
/// </summary>
public class SharedAccessKeyAuthenticationOperationFilter : IOperationFilter
{
private readonly IEnumerable<string> _scopes;

/// <summary>
/// Initializes a new instance of the <see cref="SharedAccessKeyAuthenticationOperationFilter"/> class.
/// </summary>
/// <param name="scopes">A list of API scopes that is defined for the API that must be documented.</param>
/// <remarks>It is not possible right now to document the scopes on a fine grained operation-level.</remarks>
/// <exception cref="ArgumentNullException">When the <paramref name="scopes"/> are <c>null</c>.</exception>
/// <exception cref="ArgumentException">When the <paramref name="scopes"/> has any elements that are <c>null</c> or blank.</exception>
public SharedAccessKeyAuthenticationOperationFilter(IEnumerable<string> scopes)
{
Guard.NotNull(scopes, nameof(scopes), "The sequence of scopes cannot be null");
Guard.For<ArgumentException>(() => scopes.Any(String.IsNullOrWhiteSpace), "The sequence of scopes cannot contain a scope that is null or blank");

_scopes = scopes;
}

/// <summary>
/// Applies the OperationFilter to the API <paramref name="operation"/>.
/// </summary>
/// <param name="operation">The operation instance on which the OperationFilter must be applied.</param>
/// <param name="context">Provides meta-information on the <paramref name="operation"/> instance.</param>
#if NETCOREAPP3_1
public void Apply(OpenApiOperation operation, OperationFilterContext context)
#else
public void Apply(Operation operation, OperationFilterContext context)
#endif
{
bool hasOperationAuthentication =
context.MethodInfo
.GetCustomAttributes(true)
.OfType<SharedAccessKeyAuthenticationAttribute>()
.Any();

bool hasControllerAuthentication =
context.MethodInfo.DeclaringType != null
&& context.MethodInfo.DeclaringType
.GetCustomAttributes(true)
.OfType<SharedAccessKeyAuthenticationAttribute>()
.Any();

if (hasOperationAuthentication || hasControllerAuthentication)
{
if (operation.Responses.ContainsKey("401") == false)
{
#if NETCOREAPP3_1
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
#else
operation.Responses.Add("401", new Response { Description = "Unauthorized" });
#endif
}

if (operation.Responses.ContainsKey("403") == false)
{
#if NETCOREAPP3_1
operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
#else
operation.Responses.Add("403", new Response { Description = "Forbidden" });
#endif
}
#if NETCOREAPP3_1
var scheme = new OpenApiSecurityScheme
{
Scheme = "sharedaccesskey",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hardcoded but must match what was defined as a security requirement, we should make this configurable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made it configurable. Thanks!

Type = SecuritySchemeType.ApiKey
};

operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
[scheme] = _scopes.ToList()
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
}
};
#else
operation.Security = new List<IDictionary<string, IEnumerable<string>>>
{
new Dictionary<string, IEnumerable<string>> { ["sharedaccesskey"] = _scopes }
stijnmoreels marked this conversation as resolved.
Show resolved Hide resolved
};
#endif
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public SharedAccessKeyAuthenticationAttribute(string secretName) : this(headerNa
/// </summary>
/// <param name="headerName">The name of the request header which value must match the stored secret.</param>
/// <param name="queryParameterName">The name of the query parameter which value must match the stored secret.</param>
/// <param name="secretName">The name of the secret that's being retrieved using the <see cref="ISecretProvider.Get"/> call.</param>
/// <param name="secretName">The name of the secret that's being retrieved using the <see cref="ISecretProvider.GetRawSecretAsync"/> call.</param>
/// <exception cref="ArgumentException">When the <paramref name="headerName"/> is <c>null</c> or blank.</exception>
/// <exception cref="ArgumentException">When the <paramref name="secretName"/> is <c>null</c> or blank.</exception>
public SharedAccessKeyAuthenticationAttribute(string secretName, string headerName = null, string queryParameterName = null) : base(typeof(SharedAccessKeyAuthenticationFilter))
Expand Down
2 changes: 2 additions & 0 deletions src/Arcus.WebApi.Tests.Unit/Hosting/TestApiServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
swaggerGenerationOptions.SwaggerDoc("v1", openApiInformation);
swaggerGenerationOptions.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, assemblyName + ".Open-Api.xml"));
swaggerGenerationOptions.OperationFilter<OAuthAuthorizeOperationFilter>(new object[] { new[] { "myApiScope" } });
swaggerGenerationOptions.OperationFilter<SharedAccessKeyAuthenticationOperationFilter>(new object[] { new [] { "myApiScope" } });
swaggerGenerationOptions.OperationFilter<CertificateAuthenticationOperationFilter>(new object[] { new [] { "myApiScope" } });
});
});

Expand Down
48 changes: 48 additions & 0 deletions src/Arcus.WebApi.Tests.Unit/OpenApi/AuthenticationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Net.Http;
using Arcus.WebApi.Security.Authentication.Certificates;
using Arcus.WebApi.Security.Authentication.SharedAccessKey;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Arcus.WebApi.Tests.Unit.OpenApi
{
[ApiController]
public class AuthenticationController : ControllerBase
{
public const string OAuthRoute = "openapi/auth/oauth",
SharedAccessKeyRoute = "openapi/auth/sharedaccesskey",
CertificateRoute = "openapi/auth/certificate",
NoneRoute = "openapi/auth/none";

[HttpGet]
[Route(OAuthRoute)]
[Authorize]
public IActionResult GetOAuthAuthorized()
{
return Ok();
}

[HttpGet]
[Route(CertificateRoute)]
[CertificateAuthentication]
public IActionResult GetCertificateAuthorized()
{
return Ok();
}

[HttpGet]
[Route(SharedAccessKeyRoute)]
[SharedAccessKeyAuthentication("secretName", "headerName")]
public IActionResult GetSharedAccessKeyAuthorized()
{
return Ok();
}

[HttpGet]
[Route(NoneRoute)]
public IActionResult GetNoneAuthorized()
{
return Ok();
}
}
}
Loading