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
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
public partial class PetController : ControllerBase
{
public PetController()
{
}
[HttpPost, Route("pet")]
public async Task<Pet> AddPet([FromBody] Pet body)
{
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;
}
[HttpPut, Route("pet")]
public async Task<IActionResult> UpdatePet([FromBody] object body, [FromHeader(Name = "Accept-Language")] string accept_Language, long cookieParam)
{
throw new NotImplementedException();
}
[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();
}
}
[HttpPost, Route("pet/{petId}")]
public async Task<IActionResult> UpdatePetWithForm(long petId, Microsoft.AspNetCore.Http.IFormFile body)
{
throw new NotImplementedException();
}
[HttpDelete, Route("pet/{petId}")]
public async Task<IActionResult> DeletePet(long petId)
{
if (PetData.Instance.Dic.TryGetValue(petId, out _))
{
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".
builder.Services.AddAuthentication(
options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
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),
#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:
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()
{
Name = "KKK",
PhotoUrls = new string[] { "http://somewhere.com/mydog.jpg" },
Tags = new Tag[] {
new Tag()
{
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.
[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".
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.