I have a working solution for this, but it feels a bit hackish. Other ideas welcome. The gist is, IIS handles mixed security correctly already. You can add the anonymous decorator and still have manual security checking in the method. The core problem is getting NSwag to recognize this.
On the method that needs the mixed security, I use [AllowAnonymous] and another custom attribute [OptionalSecurity]. Optional security attribute doesn't have any logic, its just there as a decorator.
Next, I added an IOperationProcessor class which checks for the OptionalSecurity attribute and when found, sets OperationProcessorContext->OperationDescription.Operation.Security to a "none" security requirement, and a scoped/security schemed requirement. Some of the code is based on the library code in AspNetCoreOperationSecurityScopeProcessor.
public class AddDefaultProducesTypesForErrorsOperationProcessor : IOperationProcessor
{
private string securitySchemeName;
/// <param name="name">The security definition name.</param>
public AddDefaultProducesTypesForErrorsOperationProcessor(string name)
{
securitySchemeName = name;
}
/// <summary>Gets the security scopes for an operation.</summary>
/// <param name="authorizeAttributes">The authorize attributes.</param>
/// <returns>The scopes.</returns>
protected virtual IEnumerable<string> GetScopes(IEnumerable<AuthorizeAttribute> authorizeAttributes)
{
return authorizeAttributes
.Where(a => a.Roles != null)
.SelectMany(a => a.Roles.Split(','))
.Distinct();
}
public bool Process(OperationProcessorContext context)
{
var aspNetCoreContext = (AspNetCoreOperationProcessorContext)context;
var endpointMetadata = aspNetCoreContext?.ApiDescription?.ActionDescriptor?.TryGetPropertyValue<IList<object>>("EndpointMetadata");
if (endpointMetadata != null)
{
if (!context.OperationDescription.Operation.Responses.Any(r => r.Key == StatusCodes.Status500InternalServerError.ToString()))
{
NJsonSchema.JsonSchema errSchema;
if (context.SchemaResolver.HasSchema(typeof(ErrorDto), false))
{
errSchema = context.SchemaResolver.GetSchema(typeof(ErrorDto), false);
}
else
{
errSchema = context.SchemaGenerator.Generate(typeof(ErrorDto), context.SchemaResolver);
errSchema.Title = nameof(ErrorDto);
}
if (!context.Document.Responses.TryGetValue("Internal Server Error", out NSwag.OpenApiResponse errResponse))
{
errResponse = new NSwag.OpenApiResponse() { Description = "Internal Server Error", Schema = new NJsonSchema.JsonSchema() { Reference = errSchema } };
context.Document.Responses.Add("Internal Server Error", errResponse);
}
//Default error handler for unhandled exceptions caught by ApiExceptionFilter
context.OperationDescription.Operation.Responses.Add(StatusCodes.Status500InternalServerError.ToString(), new NSwag.OpenApiResponse() { Description = "Internal Server Error", Reference = errResponse });
}
var authorizeAttributes = endpointMetadata.OfType<AuthorizeAttribute>().ToList();
var optSecurity = endpointMetadata.OfType<OptionalSecurityAttribute>().ToList();
//If there are any Optional Security Attributes, redefine the security schema to include both styles
if (optSecurity.Any())
{
//Adds a none definition to the security collection -> security: [ ..., {} ], and includes the oauth2 scope added by AspNetCoreOperationSecurityScopeProcessor
if (context.OperationDescription.Operation.Security == null)
{
context.OperationDescription.Operation.Security = [
new NSwag.OpenApiSecurityRequirement(),
new NSwag.OpenApiSecurityRequirement()
{
{ securitySchemeName, GetScopes(authorizeAttributes) }
}
];
}
}
//If there are any authorize attributes or the optional security attribute, add 401 and 403 responses
if (authorizeAttributes.Count != 0 || optSecurity.Count != 0)
{
if (!context.OperationDescription.Operation.Responses.Any(r => r.Key == StatusCodes.Status401Unauthorized.ToString()))
{
//User not known
if (!context.Document.Responses.TryGetValue("Unauthorized", out NSwag.OpenApiResponse unauthResponse))
{
unauthResponse = new NSwag.OpenApiResponse() { Description = "Unauthorized", Schema = new NJsonSchema.JsonSchema() { Type = NJsonSchema.JsonObjectType.String } };
context.Document.Responses.Add("Unauthorized", unauthResponse);
}
//Default error handler for unhandled exceptions caught by ApiExceptionFilter
context.OperationDescription.Operation.Responses.Add(StatusCodes.Status401Unauthorized.ToString(), new NSwag.OpenApiResponse() { Description = "Unauthorized", Reference = unauthResponse });
}
if (!context.OperationDescription.Operation.Responses.Any(r => r.Key == StatusCodes.Status403Forbidden.ToString()))
{
//Unauthorized
if (!context.Document.Responses.TryGetValue("Forbidden", out NSwag.OpenApiResponse forbidResponse))
{
forbidResponse = new NSwag.OpenApiResponse() { Description = "Forbidden", Schema = new NJsonSchema.JsonSchema() { Type = NJsonSchema.JsonObjectType.String } };
context.Document.Responses.Add("Forbidden", forbidResponse);
}
//Default error handler for unhandled exceptions caught by ApiExceptionFilter
context.OperationDescription.Operation.Responses.Add(StatusCodes.Status403Forbidden.ToString(), new NSwag.OpenApiResponse() { Description = "Forbidden", Reference = forbidResponse });
}
}
}
return true;
}
}
This lets Swagger UI know that security is optional and to send the auth token if present. Importantly, the json has dual security options as intended:
