Here is what you can do to increase the security in your api services.
In this article I will show the barebones needed to be set up to protect an Api using ClientCredentials grant type and IdentityServer4
✨ IdentityServer project:
This project has only 2 files:
- Startup.cs
- Program.cs
This is a default generate project using .NET 6.
The single dependency we have is :
<ItemGroup> <PackageReference Include="IdentityServer4" Version="4.1.2" /> </ItemGroup>
public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }); }
The startup file is straight forward. Identity server allows us to define clients and scopes in memory (of course, do not use them in production from memory), and here we are creating a Client (for our protected api) with a secret required for the caller.
public class Startup { public static IEnumerable<Client> Clients => new List<Client> { new Client { ClientId = "web-api", AllowedGrantTypes = GrantTypes.ClientCredentials, ClientSecrets = { new Secret("secret".Sha256()) }, AllowedScopes = { "web-api-scope" }, AllowedCorsOrigins = new List<string>{ "http://localhost:5000", "https://localhost:5001" } } }; public static IEnumerable<ApiScope> ApiScopes => new List<ApiScope> { new ApiScope("web-api-scope", "My API") }; 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) { services .AddIdentityServer() .AddDeveloperSigningCredential() .AddInMemoryApiScopes(ApiScopes) .AddInMemoryClients(Clients); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { IdentityModelEventSource.ShowPII = true; app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseRouting(); app.UseIdentityServer(); } }
✨ Api Project:
The api project must be configured to be authenticated against our identity server authority and the scheme we are using to authenticate is called Bearer. Again in this project we have the minimum amount of files setup:
- Startup.cs
- Program.cs
- WeatherForecastController.cs
Note that we can add multiple authentication schemes and we can configure each method from our controller to use a custom authentication scheme. This is why when we must specify what is the default scheme services.AddAuthentication(string defaultScheme) and then add our schemes .AddJwtBearer(‘Bearer’, …)
The single dependency we have is :
<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.5" /> </ItemGroup>
public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }); }
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) { services.AddControllers(); services.AddAuthorization(); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, "Bearer", options => { options.Authority = Configuration["EndPoints:IdentityAuthority"]; options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = false }; }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); app.UseRouting(); app.UseCors(builder => builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } }
✨ Test:
To test the solution, I created 3 tests and I started the 2 services. The tests are made using NUnit and as an extra dependency I added IdentityModel which allows me to quickly get the access token from the token endpoint:
All tests passed:
[Test] public async Task CallAuthorizedMethod_WithNoBearer_Fails() { HttpClient apiClient = new(); apiClient.BaseAddress = new Uri("https://localhost:5001"); HttpRequestMessage requestMessage = new HttpRequestMessage( HttpMethod.Get, new Uri("api/authorized", UriKind.Relative)); HttpResponseMessage response = await apiClient.SendAsync(requestMessage); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); } [Test] public async Task CallNotAuthorizedMethod_Succeeds() { HttpClient apiClient = new(); apiClient.BaseAddress = new Uri("https://localhost:5001"); HttpRequestMessage requestMessage = new HttpRequestMessage( HttpMethod.Get, new Uri("api/notauthorized", UriKind.Relative)); HttpResponseMessage response = await apiClient.SendAsync(requestMessage); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); } [Test] public async Task CallAuthorizedMethod_WithBearer_UsingIdentityModel_Succeeds() { var identityServcerClient = new HttpClient(); DiscoveryDocumentResponse disco = await identityServcerClient.GetDiscoveryDocumentAsync("https://localhost:6001"); TokenResponse? tokenResponse = await identityServcerClient.RequestClientCredentialsTokenAsync( new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, ClientId = "web-api", ClientSecret = "secret", Scope = "web-api-scope" }); HttpClient apiClient = new(); apiClient.BaseAddress = new Uri("https://localhost:5001"); HttpRequestMessage requestMessage = new HttpRequestMessage( HttpMethod.Get, new Uri("api/authorized", UriKind.Relative)); requestMessage.Headers.Add("Authorization", $"Bearer {tokenResponse.AccessToken}"); HttpResponseMessage response = await apiClient.SendAsync(requestMessage); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); }