JWT Authentication In Microservices

As with any Web Service, the microservices need to be protected from unauthorized access. So how do you protect each of your services? How does one share the token that is received from the Auth service? Exposing your microservices directly to the client and allowing them to directly communicate with all your services would have its own problems, so in this example, we will also add a layer of API Gateway which would be the single point of contact for all your clients.

The Gateways, in addition to providing a single point of access, also adds a security layer over your microservices. It could also support load balancing and reducing round trips when the client requires calls to multiple microservices. With regard to authentication, the gateway could pass the authentication token to the downstream paths. In this example, we would be using Ocelot for building our gateway.

The Working

The typical authentication process could be outlined as shown in the diagram below.

Microservice Authentication via Gateway

The authentication request from the client is redirected to the dedicated Auth service. This service would be responsible for validating the user and granting the authentication token. The authentication token is then returned back to the client via the gateway. In each of the subsequent requests, the client would pass the Authentication token along with the request. The API Gateway would be processed the incoming request, and if the downstream service requires an authenticated path, would pass the received token along.

We could also add BFF and other layers in the architecture, but for this article, we will keep it to a bare minimum. Let us now get our hands dirty and start building our sample application which would comprise of an API Gateway, two microservices - an auth service, and a User Service. For the sake of example, we will use Postman as our client and use Minimal API for building our services.

Auth Service

As mentioned earlier, the Auth Service would be responsible for authenticating the User and generating the Auth token. We will begin by defining our Token Service and registering it.

internal interface ITokenService
{
    string BuildToken(string key, string issuer, IEnumerable<string> audience, string userName);
}
internal class TokenService : ITokenService
{
    private TimeSpan ExpiryDuration = new TimeSpan(0, 30, 0);
    public string BuildToken(string key, string issuer, IEnumerable<string> audience, string userName)
    {
        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.UniqueName, userName),
        };

        claims.AddRange(audience.Select(aud => new Claim(JwtRegisteredClaimNames.Aud, aud)));

        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
        var tokenDescriptor = new JwtSecurityToken(issuer, issuer, claims,
            expires: DateTime.Now.Add(ExpiryDuration), signingCredentials: credentials);
        return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
    }
}

We will go ahead and register it so that we could use it with DI.

builder.Services.AddSingleton<ITokenService>(new TokenService());

We will keep the Validate endpoint simple for sake of the example and hardcode our user credentials, but this could be easily replaced with your repositories later.

app.MapPost("/validate", [AllowAnonymous] (UserValidationRequestModel request, HttpContext http, ITokenService tokenService) =>
{
    if (request is UserValidationRequestModel { UserName: "john.doe", Password: "123456" })
    {
        var token = tokenService.BuildToken(builder.Configuration["Jwt:Key"],
                                            builder.Configuration["Jwt:Issuer"],
                                            new[]
                                            {
                                                        builder.Configuration["Jwt:Aud1"],
                                                        builder.Configuration["Jwt:Aud2"]
                                                    },
                                            request.UserName);
        return new
        {
            Token = token,
            IsAuthenticated = true,
        };
    }
    return new
    {
        Token = string.Empty,
        IsAuthenticated = false
    };
})
.WithName("Validate");

Where UserValidationRequestModel is defined as

internal record UserValidationRequestModel([Required]string UserName, [Required] string Password);

The AuthService, as you could understand from the code above, exposes a single endpoint - validate which would generate an auth token if the username/password supplied are "john.doe" and "123456". The generated and subsequently returned auth token would be then used by the succeeding requests by the client for accessing authorized routes.

API Gateway

As mentioned earlier, we would be using Ocelot for building our API Gateway. The role of Gateway as far as Authentication is concerned would be to detect if the requested downstream path requires authentication and if so, pass the authorization token which was passed in the upstream request. Let us configure the routing configuration for our sample Gateway.

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/GetUsers",
      "DownstreamSchema": "https",
      "DownstreamHttpMethod": "GET",
      "RouteIsCaseSensitive": false,
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": "7163"
        }
      ],
      "UpstreamPathTemplate": "/user/getusers",
      "UpstreamHttpMethod": [ "Get" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "GatewayAuthenticationKey",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/validate",
      "DownstreamSchema": "https",
      "DownstreamHttpMethod": "POST",
      "RouteIsCaseSensitive": false,
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": "7075"
        }
      ],
      "UpstreamPathTemplate": "/user/validate",
      "UpstreamHttpMethod": [ "Post" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "https://localhost:7254"
  }
}

As you can observe in the configuration, there are two routes that are exposed in the API Gateway. The /validate route doesn't require any authentication, while the /GetUsers route requires authentication. When ocelot runs, it would check the routes AuthenticationOptions and if it exists, reads the AuthenticationProviderKey. This denotes the authentication key (scheme), which needs to be registered with the authentication services at the startup.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer("GatewayAuthenticationKey", option =>
    {
        option.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Aud"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });
builder.Configuration.AddJsonFile("ocelot.json");
builder.Services.AddOcelot(builder.Configuration);

Ocelot requires you to register the Authentication Service be registered with a name/key. Ocelot would use this key to recognize the service to use when encountered  AuthenticationProviderKey in the route declaration. If the route requires authentication, Ocelot would execute the authentication middleware registered for the provider.

UserService

The last step in this demo code would be to set up a mock User Service, a microservice that would be responsible for handling user details. We will expose a single endpoint the UserService, which would return a set of Usernames.

app.MapGet("/GetUsers", [Authorize]() =>
{
    return new[]
    {
        "John.Doe",
        "Jane.Doe",
        "Jewel.Doe",
        "Jayden.Doe",
    };
}).WithName("GetUsers");

Do not forget to register the authentication services as usual.

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(option =>
    {
        option.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Aud"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
        };
    });

That would be what we all require in the microservice end of our demo code to test our Authentication sample.

Client

As mentioned earlier, we will be using Postman to test our sample set of services. So fire up your Postman, and first send the request to validate the user and get the auth token. Remember to use the Gateway instead of using the microservice directly.

JWT Authentication In Microservices

As you can observe, we have retrieved the Auth Token from the Auth Service (via the gateway of course). We will now use the auth token to authenticate our request to access the protected route in the User Service.

JWT Authentication In Microservices

It could be observed that the request was a success and then User Service has returned the required results.

Summary

In this article, we addressed how to authenticate the different individual services in the microservice architecture. We also used the wonderful Ocelot library for building API Gateways. The services and the gateways are loosely coupled with each other, while the client is having a single point of contact in the gateway.

You could access the complete source code referred to in this article in my Github


Similar Articles