How to Use More Than One Authentication Schemes in ASP.NET Core Web Api

Although it is rare, there are a few special cases where using multiple authentication schemes in a project is necessary. One of them is a situation in which you support a less secure authentication scheme, such as basic http authentication, where credentials are essentially supplied for each request.

For internal APIs, where access is limited to users or, more likely, applications running on your company’s network, this type of lax security schema is appropriate. This means that only clients you can trust will be able to access your service over a network, not any traffic coming from outside your company.

Let’s say this service eventually needs to be exposed to the outside world, which would allow any client, including those outside your network, to access it. In this case, you would be forced to switch to a new, more secure authentication schema. Although switching to a new schema may take some time, you still have existing clients that may be different applications from various teams, so you will eventually need to support both the old and new authentication schemes.

This is a rare instance because you should always assume that your APIs could be exposed to the public and that you should take care of the authentication approach from the beginning, but you are probably already aware of cases where APIs begin as internal and eventually change as the entire product changes, necessitating a reevaluation of their security strategy.

The keys you should keep in mind when dealing with a situation similar to the one described in this article.

Implementing Custom Authentication Handlers

Since the sample will only use basic authentication methods, I’ll make two authentication handlers to deal with the http basic authentication and the api-key authentication schemas. Check out the article Basic authentication with Swagger and ASP.NET Core if you want to learn more about the basic-http authentication schema and how it is implemented in detail in this framework. I’ll only briefly go over the handler and Swagger configuration for basic-http authentication in this article.

I will use credentials from the configuration file and inject them using the options pattern as IOptions injected services before we begin with the implementation of authentication handlers.

{ 
   /*...*/ 
   "AuthenticationConfig": { 
     "Username": "john_smith", 
     "Password": "d54f04", 
     "Key": "2b1d5be65cd54f0492f4e4504179f39f" 
   } 
   /*...*/ 
}

I made a simple POCO to load config values in order to inject these configuration objects.

namespace MulitpleDb.Sample.Options 
{ 
    public class AuthenticationConfig 
    { 
        public string Username { get; set; } 
        public string Password { get; set; } 
        public string Key { get; set; } 
    } 
}

And finally, I added the following line to ConfigureServices in the startup.cs class file to register the option.

public void ConfigureServices(IServiceCollection services) 
{ 
    //... 
    services.Configure<AuthenticationConfig>(Configuration.GetSection(nameof(AuthenticationConfig))); 
    //... 
}

These will be the login credentials for both authentication methods.

Note: After the key has been validated against the configured key value, the username from the configuration will be used to set the currently logged-in user for the current HTTP request context as required by the Api-Key schema.

Since the names of the schema and header keys will be repeated throughout the code, I made all of these values constants that will be used throughout the code to prevent human error from simple typos.

public static class AuthenticationSchemaNames 
{ 
    public const string ApiKeyAuthentication = nameof(ApiKeyAuthentication); 
    public const string BasicAuthentication = nameof(BasicAuthentication); 

    public const string MixSchemaNames = nameof(ApiKeyAuthentication)+","+ nameof(BasicAuthentication); 
} 

public static class HeaderKeyNames 
{ 
public const string ApiKeyAuthenticationKey = "X-API-KEY"; 
}

Basic-http schema handler

After setting up our credentials, we can write the code to handle each schema. The handler will check to see if anyone is currently logged in to the current http request context before beginning any processing and forego credentials validation.

public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> 
{ 
    readonly AuthenticationConfig _authenticationConfig; 
    public BasicAuthenticationHandler( 
        IOptionsMonitor<AuthenticationSchemeOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock, 
        IOptions<AuthenticationConfig> authenticationConfig) 
        : base(options, logger, encoder, clock) 
    { 
        _authenticationConfig = authenticationConfig.Value; 
    } 
    protected override Task HandleChallengeAsync(AuthenticationProperties properties) 
    { 
        Response.Headers["WWW-Authenticate"] = "Basic"; 
        return base.HandleChallengeAsync(properties); 
    } 

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync() 
    { 
        if (Request.HttpContext.User.Identity.IsAuthenticated) 
            return await Task.FromResult(AuthenticateResult.NoResult()); 

        if (!string.IsNullOrWhiteSpace(Request.HttpContext.User?.Identity?.Name)) 
            return await Task.FromResult(AuthenticateResult.NoResult()); //Already authenticated 


        if (!Request.Headers.ContainsKey("Authorization")) 
            return await Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); 

        string username = null; 
        try 
        { 
            var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); 
            var credentialBytes = Convert.FromBase64String(authHeader.Parameter); 
            var credentials = Encoding.UTF8.GetString(credentialBytes).Split(new[] { ':' }, 2); 
            username = credentials.FirstOrDefault(); 
            var password = credentials.LastOrDefault(); 

            if (!username.Equals(_authenticationConfig.Username) || !password.Equals(_authenticationConfig.Password)) 
                throw new ArgumentException("Invalid username or password"); 
        } 
        catch 
        {  
            return await Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); 
        } 



        var claims = new Claim[] { new Claim(ClaimTypes.Name, _authenticationConfig.Username) }; 
        var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name); 
        var principal = new ClaimsPrincipal(claimsIdentity); 
        var ticket = new AuthenticationTicket(principal, Scheme.Name); 

        var user = new GenericPrincipal( 
           identity: claimsIdentity, 
           roles: claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray() 
           ); 
        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); 
        Request.HttpContext.User = user; 

        return await Task.FromResult(AuthenticateResult.Success(ticket)); 
    } 
}

When a user is successfully validated, ClaimsIdentity is used to set that user as the current user with a single claim, the username value.

Api-key schema handler

Because it uses unique headers and only matches a single value, this handler is much more straightforward. If the provided key in the header matches, ClaimsIdentity sets a single username claim value and the current user is set as with basic-http schema flow.

public class TokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> 
{ 
    readonly AuthenticationConfig _authenticationConfig; 
    public TokenAuthenticationHandler( 
        IOptionsMonitor<AuthenticationSchemeOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock, 
        IOptions<AuthenticationConfig> authenticationConfig) 
        : base(options, logger, encoder, clock) 
    { 
        _authenticationConfig = authenticationConfig.Value; 
    } 

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync() 
    { 
        if (Request.HttpContext.User.Identity.IsAuthenticated) 
            return await Task.FromResult(AuthenticateResult.NoResult()); 

        var headerKeyValue = Request.Headers.SingleOrDefault(h => h.Key.Equals(HeaderKeyNames.ApiKeyAuthenticationKey, StringComparison.InvariantCultureIgnoreCase)).Value.SingleOrDefault(); 

        if(string.IsNullOrEmpty(headerKeyValue) || !headerKeyValue.Equals(_authenticationConfig.Key, StringComparison.InvariantCultureIgnoreCase)) 
return await Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header")); 

        var claims = new Claim[] { new Claim(ClaimTypes.Name, _authenticationConfig.Username) }; 
        var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name); 
        var principal = new ClaimsPrincipal(claimsIdentity); 
        var ticket = new AuthenticationTicket(principal, Scheme.Name); 

        var user = new GenericPrincipal( 
            identity: claimsIdentity, 
            roles: claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray() 
            ); 
        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); 
        Request.HttpContext.User = user; 

        return await Task.FromResult(AuthenticateResult.Success(ticket)); 
    } 
}

Registering authentication services

We don’t want to bloat the Startup.cs service registration method, so I always prefer to remove the logic as extension methods.

public static class DependencyInjectionExtensions 
{ 
    public static AuthenticationBuilder AddApiKeyAuthenticationSchema(this AuthenticationBuilder authentication) 
    { 
        authentication.AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>(AuthenticationSchemaNames.ApiKeyAuthentication, o => { }); 
        return authentication; 
    } 

    public static AuthenticationBuilder AddBasicAuthenticationSchema(this AuthenticationBuilder authentication) 
    { 
        authentication.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AuthenticationSchemaNames.BasicAuthentication, o => { }); 
        return authentication; 
    } 
}

Each method returns an instance of the AuthenticationBuilder class that extends it because our application authentication needs to support multiple schemes. This enables the use of practical chaining syntax.

public void ConfigureServices(IServiceCollection services) 
{ 
    //... 
    services.Configure<AuthenticationConfig>(Configuration.GetSection(nameof(AuthenticationConfig))); 
services.AddAuthentication(o => 
    { 
        o.DefaultAuthenticateScheme = AuthenticationSchemaNames.ApiKeyAuthentication; 
    }) 
    .AddApiKeyAuthenticationSchema() 
    .AddBasicAuthenticationSchema(); 

    //... 
}

Setting up DefaultAuthenticateScheme determines which schema and its handlers will be invoked first during the authentication process because all registered authentication handlers will be called during the authentication process regardless.

Adding controller authorization

It is not sufficient to simply add the Authorize attribute to our controllers or controller methods given that we have multiple authentication schema. Define the schemes that we want to use to test credentials is another requirement.

The constructor for the Authorize attribute already accepts comma-separated schema names. We’ll just use the comma-separated value that we already have as a constant.

[Authorize(AuthenticationSchemes = AuthenticationSchemaNames.MixSchemaNames)] 
[Route("api/[controller]")] 
[ApiController] 
public class RocketsController : ControllerBase 
{ 
    [HttpGet("{rocket}")] 
    public async Task<String> Get( 
        [FromRoute]String rocket, 
        [FromQuery]RocketQueryModel query) 
    { 
        return await Task<String>.FromResult( 
            $"Rocket {rocket} launched to {query.Planet} using {Enum.GetName(typeof(FuelTypeEnum), query.FuelType)} fuel type" 
            ); 
    } 
}

Adding authentication schemas to Swagger UI

The last step before testing our authentication is to enable Swagger UI testing.

In a situation similar to dependency injection, we create separate extension methods that will be applied to an instance of the SwaggerGenOptions class in order to avoid overcomplicating and bloating the service registering method inside Startup.cs.

namespace MulitpleDb.Sample.Extensions 
{ 
    public static class SwaggerExtensions 
    { 
        public static SwaggerGenOptions AddBasicAuthSchemaSecurityDefinitions(this SwaggerGenOptions options) 
        { 
            options.AddSecurityDefinition("basic", new OpenApiSecurityScheme 
            { 
                Name = "Authorization", 
                Type = SecuritySchemeType.Http, 
                Scheme = "basic", 
                In = ParameterLocation.Header, 
                Description = "Basic Authorization header using the Bearer scheme." 
            }); 

            options.AddSecurityRequirement(new OpenApiSecurityRequirement 
                { 
                    { 
                          new OpenApiSecurityScheme 
                            { 
                                Reference = new OpenApiReference 
                                { 
                                    Type = ReferenceType.SecurityScheme, 
                                    Id = "basic" 
                                } 
                            }, 
                            new string[] {} 
                    } 
                }); 

            return options; 
     } 

     public static SwaggerGenOptions AddApiKeyAuthSchemaSecurityDefinitions(this SwaggerGenOptions options) 
     { 
         options.AddSecurityDefinition("token", new OpenApiSecurityScheme 
         { 
             Name = HeaderKeyNames.ApiKeyAuthenticationKey, 
             Type = SecuritySchemeType.ApiKey, 
             In = ParameterLocation.Header, 
             Description = "Api key from header", 
         }); 


         options.AddSecurityRequirement(new OpenApiSecurityRequirement 
                 { 
                     { 
                         new OpenApiSecurityScheme 
                         { 
                             Reference = new OpenApiReference { 
                                 Type = ReferenceType.SecurityScheme, 
                                 Id = "token" 
                             } 
                         }, 
                         new string[] {} 
                     } 
                 }); 

         return options; 
      } 
   } 
}

With these extension methods in place, we are able to test our authentication flow by simply and effectively adding our authentication schema definitions to Swagger UI, which will produce an interface for both schemes.

public void ConfigureServices(IServiceCollection services) 
        { 
            //... 
            services.AddSwaggerGen(c => 
            { 
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "MulitpleDb.Sample", Version = "v1" }); 
                c.ParameterFilter<PlanetsParameterFilter>(); 

                c.AddApiKeyAuthSchemaSecurityDefinitions().AddBasicAuthSchemaSecurityDefinitions(); 

            }).AddSwaggerGenNewtonsoftSupport(); 

            services.Configure<AuthenticationConfig>(Configuration.GetSection(nameof(AuthenticationConfig))); 

            services.AddAuthentication(o => 
            { 
                o.DefaultAuthenticateScheme = AuthenticationSchemaNames.ApiKeyAuthentication; 
            }) 
            .AddApiKeyAuthenticationSchema() 
            .AddBasicAuthenticationSchema(); 

            //... 
      }

Although it wasn’t necessary, I also used chaining enables syntax by having the extension method return the extended class instance.

We are ready to launch our Web API instance with multiple authentication schema enabled and test out our schema flows directly from Swagger UI.

To check our authentication now I’ll enter our api-key from the appsettings.json configuration file and press the Swagger UI’s “Execute” button. You can see that the X-API-KEY header values are sent if you look at the CURL command.

curl -X 'GET' \
  'https://localhost:5001/api/Rockets/R1?FuelType=Solid&Planet=Mercury' \
  -H 'accept: text/plain' \
  -H 'X-API-KEY: 2b1d5be65cd54f0492f4e4504179f39f'

Since both authentication handlers check for an authenticated user before executing any conditions against credentials, only one handler will actually execute the condition against credentials while the second handler (in this case, the basic-http schema handler) will simply skip the authentication logic if we want to check the corner case.

curl -X 'GET' \
  'https://localhost:5001/api/Rockets/R1?FuelType=Solid&Planet=Mercury' \
  -H 'accept: text/plain' \
  -H 'X-API-KEY: 2b1d5be65cd54f0492f4e4504179f39f' \
  -H 'Authorization: Basic am9obl9zbWl0aDpkNTRmMDQ='

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *