Token based authentication and Identity framework in ASP.NET Core - Part 3

Setting up asymmetric key token based authentication in ASP.NET Core application on top of Identity Framework

Recently I wrote about setting up .NET Core Identity Framework and Token based authentication in ASP.NET Core using symmetric key signing. The downside of this approach is that both identity and resource server share the same key for signing and validating the token. This technically means that resource service is potentially capable of signing the token on it's own which is wrong as it should be only available to validate the token, without being able to issue and sign a new token.

As a solution for this, there is an option to use asymmetric keys like RSA instead of symmetric HMAC key. RSA key consists of public and private key. Private key is used for signing the token and it is present only on identity service while public key which we use for validation is on both identity and resource service.

Generating RSA private/public key pair

This prevents resource service to be able to signed and issue a new token and leaves this responsibility to identity service only. To start with asymmetric keys and token authentication we first need to generate RSA private/public key pair. This can be easily done with openssl tool. I use Cygwin for this purpose and you can execute following commands from the Cygwin console.

openssl genrsa -out private.pem 2048 
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
    

Once keys are generated you can pick them up from the Cygwin folder and include them into the identity Web API service project.

.NET Framework in System.Security.Cryptography.RSA class has method for loading RSA key from the file, but it requires XML format of RSA key instead of PEM which is the output of the openssl tool. To fix this you can use any of the on line PEM to XML converters. Here just the few:

Note

Please check updated RSA key file handling for loading key pairs direly from PEM files in .NET instead of having to convert them to XML format http://bit.ly/2trcHcJ

Setting up identity .NET Core Web API service

Now, once we have our RSA private/public key pair, we can store them under Keys folder of our identity Web API project. Asymmetric identity service is different from symmetric only in startup and token generation code-wise, plus it has some new keys in config. The communication with Identity Framework layer stays intact and methods stay the same.

So lets first look at the config

{
  "ConnectionStrings": {
    "DatabaseConnection": "Data Source=.\\SQLEXPRESS;Initial Catalog=SampleIdentity;Integrated Security=SSPI;"
  },
  "Tokens": {
    "PrivateKey": "private.xml",
    "PublicKey": "public.xml",
    "Lifetime": "86400",
    "Issuer": "http://localhost:5000",
    "Audience": "http://localhost:4000"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*"
}

    

We do not have the Key setting since we use the asymmetric private/public key pair which file names are listed in config instead. Let's look at the Startup.cs class now to check the changes required there.

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Sample.Core.Common.Extensions;
using Sample.Core.Identity.Data.DbContexts;
using Sample.Core.Identity.Data.Enities;

namespace Sample.Core.Identity.Asymetric.Api
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            #region Add CORS
            services.AddCors(options => options.AddPolicy("Cors", builder =>
            {
                builder
                .AllowAnyOrigin()
                .AllowAnyMethod()
                .AllowAnyHeader();
            }));
            #endregion

            #region Add Entity Framework and Identity Framework

            services.AddDbContext<ApplicationUserDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DatabaseConnection")));

            services.AddIdentity<ApplicationUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationUserDbContext>();

            #endregion

            #region Add Authentication

            RSA publicRsa = RSA.Create();
            publicRsa.FromXmlFile(Path.Combine(Directory.GetCurrentDirectory(),
                "Keys",
                 this.Configuration.GetValue<String>("Tokens:PublicKey")
                 ));
            RsaSecurityKey signingKey = new RsaSecurityKey(publicRsa);

            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(config =>
            {
                config.RequireHttpsMetadata = false;
                config.SaveToken = true;
                config.TokenValidationParameters = new TokenValidationParameters()
                {
                    IssuerSigningKey = signingKey,
                    ValidateAudience = true,
                    ValidAudience = this.Configuration["Tokens:Audience"],
                    ValidateIssuer = true,
                    ValidIssuer = this.Configuration["Tokens:Issuer"],
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true
                };
            });

            #endregion

            services.AddMvc();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseCors("Cors");
            app.UseAuthentication();


            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseMvc();

        }
    }
}

    

You can notice that there is only difference in ConfigureServices method and it uses FromXmlFile extension method from Sample.Core.Common library. The reason for this is that FromXmlString method of RSA class is not supported in .NET Core :(. Instead we have to use custom extension method. More on this you can find at https://github.com/dotnet/corefx/issues/23686

Rsa Xml Core Not Supported By Platform

Since we are going to use this method in both identity service and resource services I put it in a separate common library as an extension method of RSA class

using System;
using System.IO;
using System.Security.Cryptography;
using System.Xml;

//https://github.com/dotnet/corefx/issues/23686

namespace Sample.Core.Common.Extensions
{
    public static class Encryption
    {
        public static void FromXmlFile(this RSA rsa, string xmlFilePath)
        {
            RSAParameters parameters = new RSAParameters();

            XmlDocument xmlDoc = new XmlDocument();
            xmlDoc.LoadXml(File.ReadAllText(xmlFilePath));

            if (xmlDoc.DocumentElement.Name.Equals("RSAKeyValue"))
            {
                foreach (XmlNode node in xmlDoc.DocumentElement.ChildNodes)
                {
                    switch (node.Name)
                    {
                        case "Modulus": parameters.Modulus = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "Exponent": parameters.Exponent = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "P": parameters.P = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "Q": parameters.Q = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "DP": parameters.DP = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "DQ": parameters.DQ = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "InverseQ": parameters.InverseQ = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                        case "D": parameters.D = (string.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break;
                    }
                }
            }
            else
            {
                throw new Exception("Invalid XML RSA key.");
            }

            rsa.ImportParameters(parameters);
        }

        public static void ToXmlFile(this RSA rsa, bool includePrivateParameters, string xmlFilePath)
        {
            RSAParameters parameters = rsa.ExportParameters(includePrivateParameters);

            File.WriteAllText(xmlFilePath,
                string.Format("<RSAKeyValue><Modulus>{0}</Modulus><Exponent>{1}</Exponent><P>{2}</P><Q>{3}</Q><DP>{4}</DP><DQ>{5}</DQ><InverseQ>{6}</InverseQ><D>{7}</D></RSAKeyValue>",
                  parameters.Modulus != null ? Convert.ToBase64String(parameters.Modulus) : null,
                  parameters.Exponent != null ? Convert.ToBase64String(parameters.Exponent) : null,
                  parameters.P != null ? Convert.ToBase64String(parameters.P) : null,
                  parameters.Q != null ? Convert.ToBase64String(parameters.Q) : null,
                  parameters.DP != null ? Convert.ToBase64String(parameters.DP) : null,
                  parameters.DQ != null ? Convert.ToBase64String(parameters.DQ) : null,
                  parameters.InverseQ != null ? Convert.ToBase64String(parameters.InverseQ) : null,
                  parameters.D != null ? Convert.ToBase64String(parameters.D) : null)
                  );
        }
    }
}

    

The account controller has only GetToken method updated, comparing it to the symmetric key solution descried in the previous article

        private String GetToken(IdentityUser user)
        {
            var utcNow = DateTime.UtcNow;

            using (RSA privateRsa = RSA.Create())
            {
                privateRsa.FromXmlFile(Path.Combine(Directory.GetCurrentDirectory(),
                                "Keys",
                                 this.configuration.GetValue<String>("Tokens:PrivateKey")
                                 ));
                var privateKey = new RsaSecurityKey(privateRsa);
                SigningCredentials signingCredentials = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256);


                var claims = new Claim[]
                {
                        new Claim(JwtRegisteredClaimNames.Sub, user.Id),
                        new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName),
                        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
                        new Claim(JwtRegisteredClaimNames.Iat, utcNow.ToString())
                };

                var jwt = new JwtSecurityToken(
                    signingCredentials: signingCredentials,
                    claims: claims,
                    notBefore: utcNow,
                    expires: utcNow.AddSeconds(this.configuration.GetValue<int>("Tokens:Lifetime")),
                    audience: this.configuration.GetValue<String>("Tokens:Audience"),
                    issuer: this.configuration.GetValue<String>("Tokens:Issuer")
                    );

                return new JwtSecurityTokenHandler().WriteToken(jwt);
            }
        }


    

These are all the changes required to switch identity service from symmetric to asymmetric keys usage. Next part are changes on the resource service Web API project to adapt to RSA asymmetric keys.

Setting up resource service for asymmetric keys token authentication

For resource service ASP.NET Core Web API project changes are only in the config and service configuration in Startup.cs class. Of course we also need to include our public key in XML format in the resource Web API project under Keys folder. Let's check out the config first

{
  "Tokens": {
    "PublicKey": "public.xml",
    "Lifetime": "86400",
    "Issuer": "http://localhost:5000",
    "Audience": "http://localhost:4000"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*"
}

    

You can see that we are only referencing the public key which we are going to load in Startup.cs class in the same way we are doing it for identity service

#region Add Authentication

            RSA publicRsa = RSA.Create();
            publicRsa.FromXmlFile(Path.Combine(Directory.GetCurrentDirectory(),
                            "Keys",
                             this.Configuration.GetValue<String>("Tokens:PublicKey")
                             ));
            RsaSecurityKey signingKey = new RsaSecurityKey(publicRsa);

            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            }).AddJwtBearer(config =>
            {
                config.RequireHttpsMetadata = false;
                config.SaveToken = true;
                config.TokenValidationParameters = new TokenValidationParameters()
                {
                    IssuerSigningKey = signingKey,
                    ValidateAudience = true,
                    ValidAudience = this.Configuration["Tokens:Audience"],
                    ValidateIssuer = true,
                    ValidIssuer = this.Configuration["Tokens:Issuer"],
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true
                };
            });
#endregion
    

Our Values controller stays unchanged and simple test from POSTMAN can confirm that it will reply with 200 OK only if the valid token is provided which is generated by the identity service with asymmetric RSA key signing implemented. 

References

Disclaimer

Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.


About the author

DEJAN STOJANOVIC

Dejan is a passionate Software Architect/Developer. He is highly experienced in .NET programming platform including ASP.NET MVC and WebApi. He likes working on new technologies and exciting challenging projects

CONNECT WITH DEJAN  Loginlinkedin Logintwitter Logingoogleplus Logingoogleplus

JavaScript

read more

SQL/T-SQL

read more

Umbraco CMS

read more

PowerShell

read more

Comments for this article