0

I have an ASP.NET Core 8 Web API with a Swagger page using Nswag + OpenAPI3. I also have an OAuth2 security implementation which is working great. Thanks to AspNetCoreOperationSecurityScopeProcessor and [AllowAnonymous] decorations, the swagger UI handles mixed (but binary) security correctly.

However, I have a handful of 'weird' endpoints that return a mixture of public and privileged information. If the request specifically asks for private information, then the API does an authorize check.

For example, something like this:

[AllowAnonymous]
[HttpGet] 
public ActionResult<SomeDto> GetInfo(bool includePrivateInfo) 
{
    if (includePrivateInfo) 
    {
        this.authorizationService.RequireLoggedIn();   // throws, resulting in a Forbid();
    }

    SomeDto result = dataService.GetInfo(includePrivate); //get some data

    return result;
}

In the Javascript client app that uses this, I call the endpoint and add the authorization token to the request if the user was logged in (sort of an "if you have a token, send it" design). If the user isn't logged in, we still call the same endpoint. I don't want to have two different endpoints in the API, one for authenticated and one for anonymous since this keeps the client code smaller.

The problem is, I can't get the swagger client to optionally send the authorization token as long as AllowAnonymous is on the action method.

Things I have tried:

  • If I add [AllowAnonymous] to the GetInfo() method, then Swagger will not send the authorization token with a request to this endpoint, even if I'm authorized.
  • If I add [Authorize] to GetInfo() (and remove AllowAnonymous), then ASP.NET Core rejects the request when you're not authorized in Swagger.
  • I tried adding a toothless [Authorize(Policy = "Optional")] to the method, and in startup added options.AddPolicy("Optional", policy => policy.RequireAssertion(context => true));, but ASP.NET Core is smarter than that and still checks if the user is authenticated first, at some lower level, so its still requiring an auth token.

I found this relevant question/answer which seems to indicate what I want is possible, how do I achieve multiple security schemes on an endpoint, using Swagger/NSwag spec generation, where one is anonymous?

1
  • IMHO don't. Write two endpoints, even if they share the same internal implementation. Commented yesterday

1 Answer 1

0

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:
enter image description here

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.