Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / security / Identity

Share Identity Bearer Tokens among ASP.NET Core Web APIs

5.00/5 (2 votes)
11 May 2024CPOL3 min read 5K  
Share Identity Bearer Tokens among Distributed ASP.NET Core Web APIs

Introduction

This article serves as a follow-up to "Decouple ASP.NET Core Identity, Authentication and Database Engines". In this sequel, we delve into software engineering practices that promote decoupling, making them conducive to Test-Driven Development (TDD) and rapid development of enterprise applications, in contrast to god assembly implicitly promoted by the scaffolding codes of Visual Studio project templates.

Background

Since the early days of .NET Framework 1 and 2, Microsoft has introduced elegant architectural designs for securing application code across various program hosts—such as WinForms, WPF, Windows services, and ASP.NET (Core). As a .NET programmer, you can simply decorate relevant functions with the AuthorizeAttribute from various namespaces, depending on the host type. The .NET runtime then handles authentication and authorization seamlessly, leveraging appropriate configurations from app code or configuration files.

By embracing .NET Component Design and minimizing coupling between main business logic and the host, you can keep your current host’s code concise. Additionally, this approach ensures smoother migration to new hosting types in the future.

The security architecture introduced in this article has been existing in ASP.NET, well before ASP.NET Core, and the difference is ASP.NET Core has better DI/IoC.

Using the code

Comparing with the prior article, this one mainly uses "PetWebApi" as an example. The PetController was generated through some Swagger code gen upon "PetStore.yaml", and the "PetStoreClientApi" is generated using OpenApiClientGen.

Here we just need to focus on some Web API functions implemented.

Decorate Controller or Function with AuthorizeAttribute

C#
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
public partial class PetController : ControllerBase
{
    public PetController()
    {
    }

    /// <summary>Add a new pet to the store<. If you give header transaction-id, it will give back the same/summary>
    /// <param name="accept_Language">The language you prefer for messages. Supported values are en-AU, en-CA, en-GB, en-US</param>
    /// <param name="cookieParam">Some cookie</param>
    [HttpPost, Route("pet")]
    public async Task<Pet> AddPet([FromBody] Pet body)//, [FromHeader(Name = "Accept-Language")] string accept_Language, long cookieParam)
    {
        long key = PetData.Instance.GetCurrentMax();
        body.Id = key;
        PetData.Instance.Dic.TryAdd(key, body);
        Response.Headers.Add("transaction-id", Request.Headers["transaction-id"]);
        return body;
    }

    /// <summary>Update an existing pet</summary>
    /// <param name="accept_Language">The language you prefer for messages. Supported values are en-AU, en-CA, en-GB, en-US</param>
    /// <param name="cookieParam">Some cookie</param>
    [HttpPut, Route("pet")]
    public async Task<IActionResult> UpdatePet([FromBody] object body, [FromHeader(Name = "Accept-Language")] string accept_Language, long cookieParam)
    {
        throw new NotImplementedException();
    }

    /// <summary>Find pet by ID</summary>
    /// <param name="petId">ID of pet to return</param>
    /// <returns>successful operation</returns>
    [HttpGet, Route("pet/{petId}")]
    public async Task<ActionResult<Pet>> GetPetById(long petId)
    {
        if (PetData.Instance.Dic.TryGetValue(petId, out Pet p))
        {
            return p;
        }
        else
        {
            return NotFound();
        }
    }

    /// <summary>Updates a pet in the store with form data</summary>
    /// <param name="petId">ID of pet that needs to be updated</param>
    [HttpPost, Route("pet/{petId}")]
    public async Task<IActionResult> UpdatePetWithForm(long petId, Microsoft.AspNetCore.Http.IFormFile body)
    {
        throw new NotImplementedException();
    }

    /// <summary>Deletes a pet</summary>
    /// <param name="petId">Pet id to delete</param>
    [HttpDelete, Route("pet/{petId}")]
    public async Task<IActionResult> DeletePet(long petId)
    {
        if (PetData.Instance.Dic.TryGetValue(petId, out _)) //not to TryRemove for testing
        {
            return Ok();
        }
        else
        {
            return NotFound("NoSuchPet");
        }
    }

Configurates the Host Program

Now we configurate the host program for checking the right bearer tokens.

Please note, PetWebApi has no knowledge of ASP.NET Core Identity and its database, and it will just trust the bearer token produced by "Core3WebApi".

 

C#
builder.Services.AddAuthentication(
    options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; //Bearer
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    }
).AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;
    options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters()
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidAudience = authSettings.Audience,
        ValidIssuer = authSettings.Issuer,
        IssuerSigningKey = issuerSigningKey,
#if DEBUG
        ClockSkew = TimeSpan.FromSeconds(2), //Default is 300 seconds. This is for testing the correctness of the auth protocol implementation between C/S.
#endif
    }; 
});

PetWebApi Trusts the Bearer Token Produced by Core3WebApi

The clients need to obtain a proper bearer token from localhost:5000 before talking to PetWebApi on localhost:6000:

C#
public class PetsFixture : DefaultHttpClientWithUsername
{
    public PetsFixture()
    {
        Uri baseUri = new("http://localhost:6000");

        httpClient = new System.Net.Http.HttpClient
        {
            BaseAddress = baseUri,
        };

        httpClient.DefaultRequestHeaders.Authorization = AuthorizedClient.DefaultRequestHeaders.Authorization;

        Api = new PetClient(httpClient, new Newtonsoft.Json.JsonSerializerSettings()
        {
            NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore
        });
    }

    public PetClient Api { get; private set; }

    readonly System.Net.Http.HttpClient httpClient;
...
}

public partial class PetsApiIntegration : IClassFixture<PetsFixture>
{
    public PetsApiIntegration(PetsFixture fixture)
    {
        api = fixture.Api;
    }

    readonly PetClient api;

    [Fact]
    public async Task TestGetPet()
    {
        Pet d = await api.GetPetByIdAsync(12);
        Assert.Equal("Narco", d.Name);
    }

    [Fact]
    public async Task TestAddPet()
    {
        await api.AddPetAsync(new Pet()
        {
            //Id=339,
            Name = "KKK", //required
            PhotoUrls = new string[] { "http://somewhere.com/mydog.jpg" }, //required,
            Tags = new Tag[] { //not required. However, when presented, it must contain at least one item.
                new Tag()
                {
                    //Id=3,
                    Name="Hey"
                }
            },
        });
    }

    [Fact]
    public async Task TestPetsDelete()
    {
        WebApiRequestException ex = await Assert.ThrowsAsync<WebApiRequestException>(() => api.DeletePetAsync(9));
        Assert.Equal("NoSuchPet", ex.Response);
    }

Before running the test suite, launch Core3WebApi through "StartCoreWebApi.ps1" and PetWebApi through "StartPetStoreapi.ps1".

Now you see how JWT is stateless.

And if the token is expired, the client will get the Unauthorized status code.

C#
[Fact]
public async Task TestFindPetsTokenExpiresThrows()
{
    Pet[] aa = await api.FindPetsByStatusAsync(PetStatus.sold);
    Assert.Equal(3, aa.Length);
    Thread.Sleep(7050);
    var ex = await Assert.ThrowsAsync<WebApiRequestException>(() => api.FindPetsByStatusAsync(PetStatus.sold));
    Assert.Equal(System.Net.HttpStatusCode.Unauthorized, ex.StatusCode);
}

Shared Secret

To make such "distributed" authentication work, surely there should be some shared secret among parties of Web APIs and the primary secret is "IssuerSigningkey".

About ValidateIssuerSigningKey

According to Microsoft Learn: 

It is possible for tokens to contain the public key needed to check the signature. For example, X509Data can be hydrated into an X509Certificate, which can be used to validate the signature. In these cases it is important to validate the SigningKey that was used to validate the signature. This boolean only applies to default signing key validation. If IssuerSigningKeyValidator is set, it will be called regardless of whether this property is true or false. The default is false.

However, at least with Bearer token, even if this property is set to false, invalid or different IssuerSigningKey causes Unauthorize error. And this should obviously be the way, since the key is the primary shared secret.

Windows and Azure Cloud as well as other cloud providers provide some valet to store shared secrets. Discussing how to store such secret for production is out of the scope of this article, while there are many good references:

Points of Interest

In "Introduction to Identity on ASP.NET Core", Microsoft suggests:

ASP.NET Core Identity adds user interface (UI) login functionality to ASP.NET Core web apps. To secure web APIs and SPAs, use one of the following:

Microsoft actually has provided an article about using Identity with SPA:

Such features have been long existing with ASP.NET Identity on .NET Framework.

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)