DEV Community

Kevin Le
Kevin Le

Posted on

Securing C#/.NET WebAPI with public-private-key-signed JWTs signed by NodeJS

This article was cross-posted on Medium

In this article, I will show how to implement and secure a C#/.NET (hereinafter I will only say C#) WebAPI. To secure the WebAPI, we will use JWT. The JWT is signed by a NodeJS backend using Private Key. The WebAPI will verify the JWT using the Public Key.

I'd like to be clear so let me clarify some the terminologies that I prefer to use. When I say client, I mean a client application such as mobile app, a web application, Postman, etc. On the other had, a user is a human who uses those clients. When a client sends a login request to the server, it's actually doing it on behalf of user who enters his/her name on the mobile app and tab Submit button.

So with that, the client first makes the request to /login endpoint of the NodeJS server. This NodeJS server is the Authorization Server. Its job is to issue JWT if the login is correct. Assume it is, once the client obtains the JWT, the client can store this JWT in memory, or in local storage or cookie or elsewhere. Now the client wants to access the resources provided by the C# WebAPI. So when it sends a request, it includes a JWT in the Authorization attribute of the request header. The C# WebAPI is the Resource Server or Provider. Its job is to provide resources. But it only does so if it can verify the JWT.

In a sequence diagram:

Sequence diagram

The Authorization Server (NodeJS) and the Resource Provider (C# WebAPI) can run on 2 totally different servers or clouds. Instead of using public private key to sign and verify the JWT like in his article, we could also have used a shared secret that is known by both the Authorization Server (NodeJS) and the Resource Provider (C# WebAPI). However, the shared secret approach is not as effective as the public private key approach for the following reasons.

  1. There are 2 potential points of failure instead of 1. Either the Authorization Server or the Resource Provider could compromise the shared secret. On the other hand, the private key can still be compromised, but there's only one entity that knows about the private key.

  2. If there are multiple Resource Providers, sharing 1 secret only increases the number of potential points of failure.

  3. Having a different secret for each Resource Provider is an option, but in some cases we don't have control of the Resource Provider, then we have to deal with the problem of distributing of the shared secrets.

Anyway, let's generate public and private keys.

Generate Public Private Key

On a Windows computer,

$ ssh-keygen -t rsa -b 4096 -f jwtRS256.key
$ openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
Enter fullscreen mode Exit fullscreen mode

Credit: https://gist.github.com/ygotthilf/baa58da5c3dd1f69fae9

On a Mac,

$ openssl genrsa -out jwtRS256.key 4096
$ openssl rsa -in jwtRS256.key -pubout -out jwtRS256.key.pub
Enter fullscreen mode Exit fullscreen mode

Credit: https://gist.github.com/h-sakano/84dc4bd8371be4f0e8dddc9388974348#file-file0-sh

The file jwtRS256.key.pub is the public key and will be served as a static file. This will be shown later. The file jwtRS256.key is the private key and we will use it to sign the JWT.

Sign the JWT in NodeJS

We will write a NodeJS server code that has an endpoint called /login and accepts a POST request. The body of the POST request contains the userid and password in JSON format.

Run npm init and install the necessary packages:

$ npm init -y
$ npm i --save express path body-parser

Enter fullscreen mode Exit fullscreen mode

Create a public and a private folder and move the public jwtRS256.key.pub and private key jwtRS256.key files to those folders respectively.

Create a file called server.js with the content show in the screenshot below.

At this point the file structure and the server.js file should look like:

Figure 1

(Can't copy and paste, don't worry, this code will be completed and available then. Just read on)

We have not really done anything yet. But you can see the place holders. If the correct userid and password are entered, we will generate a signed JWT and return with a status code 200. Otherwise, we return with status of 401. The logic to check for userid and password is up to you.

If you run the NodeJS server locally at this point, you can use Postman or your browser to go address http://localhost:8080/jwtRS256.key.pub, the public key is readily available.

Now we install the jsonwebtoken package, which is the essence of signing the JWT and also fs.

npm i --save jsonwebtoken
npm i --save fs
Enter fullscreen mode Exit fullscreen mode

Now the complete code:

const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const fs = require('fs');
const jwt = require('jsonwebtoken');

const app = express();
const router = express.Router();

const port = process.env.PORT || 8080;

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));

app.post('/login', (req, res) => {
    const { userid, password } = req.body;

    if (userid === 'kevin' && password === '123456') { //replace with your logic
        const privateKey = fs.readFileSync(__dirname + '/private/jwtRS256.key', 'utf8');
        const issuer = 'Name-of-Issuer-that-you-want';
        const subject = 'Subject-name';
        const audience = 'Your-audience';
        const expiresIn = '24h';
        const algorithm = 'RS256'; //important
        const payload = { userid };

        const signOptions = {
            issuer,
            subject,
            audience,
            expiresIn,
            algorithm
        }

        const token = jwt.sign(payload, privateKey, signOptions);

        console.log(token);

        res.status(200).json( {token} );
    } else {
        res.status(401).json('Incorrect userid and/or password');
    }
});

app.listen(port);
module.exports = app;
Enter fullscreen mode Exit fullscreen mode

There are only 3 lines that are more important than the rest. The first line is reading the private key (const privateKey = ...). The second line is assigning 'RS256' to algorithm. The third line is the one where the token is signed (const token = jwt.sign(...))

Now launch Postman, and make a POST request like in the figure below, you will get a JWT in the response.

Login to NodeJS

Verify the JWT in C# WebAPI

As you see, a JWT is returned in the response. Where to store this JWT depends on which kind of client app you are developing, mobile, web application or Electron desktop, etc.

What I will show next is how to secure a C# WebAPI resource.

So in Visual Studio 2017 or 2015, just use the WebAPI Project template to create a new solution.

You will see a file called ValuesController.js with the following code generated for you.

public class ValuesController : ApiController
{
    // GET api/values
    public async Task<IEnumerable<string>> Get()
    {
        await Task.Delay(10);
        return new string[] { "value1", "value2" };
    }
    ...
}

Enter fullscreen mode Exit fullscreen mode

Right now, this endpoint GET api/values is unprotected. Let's go ahead and secure this endpoint.

Modify this file by adding one single line

public class ValuesController : ApiController
{
    // GET api/values
    [JwtAuthorization]
    public async Task<IEnumerable<string>> Get()
    {
        await Task.Delay(10);
        return new string[] { "value1", "value2" };
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

JwtAuthorization is a class that we will write. It subclasses from AuthorizationFilterAttribute. Before I'll show it, we have to install a Nuget package called BouncyCastle.

BouncyCastle

Then let's write a class that reads the public key. Remember the public key is a static file served at address http://localhost:8080/jwtRS256.key.pub

Since the public only has to be read once, I just create singleton for it.

public class PublicKeyStore
{
    private readonly string URL = "http://localhost:8080/jwtRS256.key.pub";
    private static PublicKeyStore _instance;
    private string _publicKey;

    public static async Task<PublicKeyStore> GetInstance()
    {
        if (_instance == null)
        {
            _instance = new PublicKeyStore();
            await _instance.FetchPublicKey();
        }

        return _instance;
    }

    public string PublicKey
    {
        get { return _publicKey; }
    }

    private async Task FetchPublicKey()
    {
        using (HttpClient client = new HttpClient())
        {
            using (HttpResponseMessage response = await client.GetAsync(URL))
            using (Stream receiveStream = await response.Content.ReadAsStreamAsync())
            {
                using (StreamReader readStream = new StreamReader(receiveStream, Encoding.UTF8))
                {
                    _publicKey = readStream.ReadToEnd();
                }
            }
        }
    }

    private PublicKeyStore()
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we get to the most important part which is verifying the JWT. As I mentioned, this will be done in the JwtAuthorization class which overrides the OnAuthorization(HttpActionContext actionContext) of the base class AuthorizationFilterAttribute

public class JwtAuthorizationAttribute : AuthorizationFilterAttribute
{
    public override async void OnAuthorization(HttpActionContext actionContext)
    {
        try
        {
            if (actionContext.Request.Headers.Authorization == null)
            {
                actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
            }
            else
            {
                var bearer = actionContext.Request.Headers.Authorization.Scheme;
                var jwt = actionContext.Request.Headers.Authorization.Parameter;                    
                if (bearer.ToLower() != "bearer" || jwt == null)
                {
                    actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
                }
                else
                {
                    var publicKeyStore = await PublicKeyStore.GetInstance();
                    var publicKey = publicKeyStore.PublicKey;

                    var pr = new PemReader(new StringReader(publicKey));
                    var asymmetricKeyParameter = (AsymmetricKeyParameter)pr.ReadObject();
                    var rsaKeyParameters = (RsaKeyParameters)asymmetricKeyParameter;
                    var rsaParams = DotNetUtilities.ToRSAParameters((RsaKeyParameters)asymmetricKeyParameter);                        
                    var rsaCsp = new RSACryptoServiceProvider();
                    rsaCsp.ImportParameters(rsaParams);

                    string[] jwtParts = jwt.Split('.');
                    if (jwtParts.Length < 3)
                    {
                        actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
                    }
                    else
                    {
                        var sha256 = SHA256.Create();
                        var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(jwtParts[0] + '.' + jwtParts[1]));

                        var rsaDeformatter = new RSAPKCS1SignatureDeformatter(rsaCsp);
                        rsaDeformatter.SetHashAlgorithm("SHA256");

                        if (!rsaDeformatter.VerifySignature(hash, FromBase64Url(jwtParts[2])))
                        {
                            actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
                        }
                        else
                        {
                            byte[] data = Convert.FromBase64String(jwtParts[1]);
                            var payload = Encoding.UTF8.GetString(data);
                            //Check for time expired claim or other claims
                        }
                    }

                    base.OnAuthorization(actionContext);
                }
            }
        }
        catch (Exception)
        {
            actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized, "JWT is rejected");
        }
    }
    private static byte[] FromBase64Url(string base64Url)
    {
        string padded = base64Url.Length % 4 == 0
                ? base64Url : base64Url + "====".Substring(base64Url.Length % 4);
        string base64 = padded.Replace("_", "/")
                                  .Replace("-", "+");
        return Convert.FromBase64String(base64);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now go to Postman, and make a Post request to where your WebAPI is running, pass in the JWT that you got above (using the bearer scheme) in the Authorization attribute, you will the response with status 200.

Call WebAPI endpoint

Without the JWT or with a different scheme will result in a 401 Unauthorized.

Points of interests

1- Instead of the following code

...
var pr = new PemReader(new StringReader(publicKey));
var asymmetricKeyParameter = (AsymmetricKeyParameter)pr.ReadObject();
...
Enter fullscreen mode Exit fullscreen mode

I have seen

...
var keyBytes = Convert.FromBase64String(publicKey);
var asymmetricKeyParameter = PublicKeyFactory.CreateKey(keyBytes);
...
Enter fullscreen mode Exit fullscreen mode

The problem is with the latter, the following FormatException was thrown

The format of s is invalid. s contains a non-base-64 character, more than two padding characters, or a non-white space-character among the padding characters.
Enter fullscreen mode Exit fullscreen mode

2- The JwtAuthorizationAttribute filter runs asynchronously because of the singleton reading the public key is also asynchronously. To ensure the filter always runs before the controller method, I artificially introduced a delay of 10 ms. However as I said, the public key only has to be read once, and after that, it's available in memory. Therefore if every request gets penalized 10 ms, that does not seem fair. So I'm looking for a better solution.

Finally, if you want source code, I'm still tidying it up. In the mean time, you could help motivating me by giving this article a like and sharing it.

Top comments (0)