Introduction
I got many questions how the ASP.NET Core ApiBoilerplate protects the API
endpoints and which URL validates the token. If you are new to ASP.NET Core and are using the template to get started, chances are you might ask the same question to yourself. I hope this post clears some confusion about it.
I'll try to make this post as generic as possible so that you can still apply the same concept even if you are not using the ApiBoilerplate
template.
Getting Started
In ASP.NET Core or even in traditional Web APIs, we would normally decorate our Controllers
or Actions
with the [Authorize] attribute to secure our Api endpoints from anonymous, unauthorized or unauthenticated users:
Controller Level Restriction
[Authorize]
public class PersonController: ControllerBase
{
//rest of the code here...
}
Action Level Restriction
[HttpGet]
[Authorize]
public async Task<IEnumerable<PersonResponse>> Get()
{
//rest of the code here...
}
The [Authorize]
attribute provides filters for users and roles and it’s fairly easy to implement it if you are using membership/identity provider. You can even customize it's behavior by extending the [AuthorizeAttribute]
class based on your custom needs. There are two ways in which we can implement authorization in ASP.NET Core. These include role-based authorization and policy-based authorization. From the example above, it doesn't specify any arguments for the [Authorize]
attribute, which means that it only checks if the user is authenticated. This is sufficient enough for the purpose of this demo.
There are many possible options that we can use to secure our APIs
, a few of the most popular are using OAuth 2.0 Access Tokens
, Api Keys
or JSON Web Tokens(JWT)
. Here's an article explaining their differences: API Keys vs OAuth Tokens vs JSON Web Tokens
In this demo we are going to use JWT
as access token which contains a JSON data payload that is signed and serialized.
Why JWT?
Because it's simple and a great technology for authenticating APIs and server-to-server authorization. I'm not going to cover the details about JWT
as there are already tons of guides about this, and I personally recommend this one: Introduction to JSON Web Tokens
Please keep in mind that a JWT
guarantees data ownership but not encryption; the JSON
data you store into a JWT
can be seen by anyone that intercepts the token, as it’s just serialized, not encrypted. In other words, using JWT
doesn't make our API
invulnerable. For this reason, an extra layer of protection that always goes hand in hand with JWT
is to secure all our network traffic with an HTTPS
connection.
What IdentityServer4 Is?
IdentityServer4 an OpenID Connect
and OAuth 2.0
framework that provides a set of services and middleware for ASP.NET Core apps. I won't be covering all features in this post, and I would recommend you to head over to the official documentation page to see what features it provides.
IdentityServer4 supports multiple protocol flows or grant types such as Authorization Code
, Client Credentials
, Refresh Token
, Implicit
and etc. In this post we are going to take a look at the Client Credentials
flow.
Client Credentials Flow
Client Credentials Flow
is a process in which client apps use client_id
, client_secret
and sometimes a scope
in exchange for an access_token
to access a protected resource.
This flow is the recommended way to secure APIs easily without a particular user connected, mostly this approach is better in server-to-server scenarios, when interconnected internal applications within a system need to authenticate without Login UI to present username and password.
For more information about IdentityServer 4 supported grant types, see: Grant Types
But What the Heck is OAuth and OpenID Connect (OIDC)?
Many folks that I know virtually in the forums and in person were asking about the distinctions between the two terms: OAuth
and OpenID
. I thought I'd give a brief overview about them in this post to help you better understand each terms.
In general, OpenID
is about authentication (proving who you are (a.k.a identity)). Authentication
is a process in which a user provides credentials, typically in a form of username and password that are then compared to those stored in an database, application or resource. OAuth
on the other hand is about authorization (to grant access to files/resource/data without having to deal with the original authentication). Authorization
refers to the process that determines what a user is allowed to do after they have been authenticated. The other thing called OpenID Connect
does both.
We will use .NETCore 3.1
and IdentityServer4
framework to provide most of the security features that an application requires based OAuth 2.0
and OIDC
protocol implementation. This enables a third-party app to obtain a limited access to an HTTP
service and APIs within your server premise. Instead of using resource owner's credentials to directly access a protected resource from our APIs, the client obtains an access token. In OAuth 2.0
, access tokens are normally a string denoting a specific scope, lifetime and other access attributes.
Always remember that:
OAuth 2.0
is an authorization protocol that specifies how tokens are transferred.There is no defined structure for the token required by the spec, which means you can generate a string and implement tokens however you want.JWT
defines a token format.OAuth 2.0
can useJWT
as a token format. This is why we will useJWT
in concert withOAuth
to obtain an access token.
Just to give you a quick overview, here's a glossary of OAuth
terms:
Resource Owner
(a.k.a the User) - An entity capable of granting access to a protected resource.Resource Server
(a.k.a your ASP.NET Core APIs) - The server hosting the protected resource, capable of accepting and responding to protected resource requests using access tokens.Client
- An application (desktop, web, service or mobile app) making protected resource requests on behalf of the resource owner and with its authorization.Token Server
- The service issuing access tokens to the client after successfully authenticating the resource owner and obtaining authorization.
We're not going to cover Resource Owner
in this example as we won't be doing with user authentication that allow users to input their account credentials. Instead, we will focus on API to API authentication and authorization using client credentials grant type.
To make it clearer, here's a simple diagram describing the high-level flow on how each component interact with each other.
Building a Token Server with IdentityServer4
Let's build a simple Token Server
using IdentityServer4
that authorizes internal/external client apps for accessing a certain Resource Server
. You can think of it as a system that generates a simple data structure containing Authorization
and/or Authentication
information. In this post, we'll only cover the OAuth
aspect of IdentityServer4
to generate an access_token
in a form of JWT
for authorizing access. This would be the "keys to the house", so to speak, letting you through the doorway and into the residence of a protected resource, usually an ASP.NET Core Web APIs. A good practice is to host this server as a separate service and not implement it within your web application specific apps.
For this particular demo, I'm going to use the following tools and frameworks:
- Visual Studio 2019
- .NET Core 3.1
- ASP.NET Core 3.1
- IdentityServer4
- IdentityServer4.AccessTokenValidation
- ApiBoilerPlate.AspNetCore
To begin, let's go ahead and fire up Visual Studio 2019. Create a new ASP.NET Core Web Application project with an Empty project template and make sure Authentication option is unchecked.
Install the latest version of IdentityServer4
Nuget Package:
PM> Install-Package IdentityServer4 -Version 3.1.1
Note: The latest version as of this time of writing is 3.1.1.
For the sole purpose of this demo, we'll just use an In-Memory data store to keep the list of clients and resources. In real/production applications, you should store these data in a persistent data store such as a database. IndentityServer4 has persistent layer support baked for Entity Framework Core and you can use it to generate the schema needed for storing clients, scopes and grants.
Now, let's a create a couple of static internal classes that house some test data for clients
and resources
.
Configuring API Resources
Here's the code for our test API resources:
using IdentityServer4.Models;
using System.Collections.Generic;
namespace IdentityServer4Demo.TokenServer.Data
{
internal static class ResourceManager
{
public static IEnumerable<ApiResource> Apis =>
new List<ApiResource>
{
new ApiResource {
Name = "app.api.whatever",
DisplayName = "Whatever Apis",
ApiSecrets = { new Secret("a75a559d-1dab-4c65-9bc0-f8e590cb388d".Sha256()) },
Scopes = new List<Scope> {
new Scope("app.api.whatever.read"),
new Scope("app.api.whatever.write"),
new Scope("app.api.whatever.full")
}
},
new ApiResource("app.api.weather","Weather Apis")
};
}
}
The ApiResource
object is a class that lives within IdentityServer4.Models
namespace and is built-in to IdentityServer4. This enable us to mock API Resources (a.k.a ASP.NET Core / Web APIs) that we wish to protect. From the code example above, we are modelling two APIs that we want to protect: The app.api.whatever
and app.api.weather
.
Notice that each ApiResource
object are defined differently. The first item uses the default constructor with no parameters. You would use this approach if you wanted to configure multiple scopes per API
. In this example, we've defined three scopes: app.api.whatever.read
, app.api.whatever.write
and app.api.whatever.full
.
The second item uses the constructor parameter for convenience. You would typically use this for simpler scenarios where you only require one scope per API
. In this case, the app.api.weather
name will automatically become a scope
for the resource.
Note: A couple of things to keep in mind here is that each scopes should be unique, and that is why it's recommended to define your scopes based on each
API
unique name. You can also define theApiResource
inJSON
configuration file (appsettings.json
) and then pass the configuration section to theAddInMemoryApiResource
method.
The next thing that we need to do is to setup some test clients that we want to authorize access to our configured ApiResources
.
Configuring Clients
Here's the code for our test clients:
using IdentityServer4.Models;
using System.Collections.Generic;
namespace IdentityServer4Demo.TokenServer.Data
{
internal static class ClientManager
{
public static IEnumerable<Client> Clients =>
new List<Client>
{
new Client
{
ClientName = "Client Application1",
ClientId = "t8agr5xKt4$3",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("eb300de4-add9-42f4-a3ac-abd3c60f1919".Sha256()) },
AllowedScopes = new List<string> { "app.api.whatever.read", "app.api.whatever.write" }
},
new Client
{
ClientName = "Client Application2",
ClientId = "3X=nNv?Sgu$S",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("1554db43-3015-47a8-a748-55bd76b6af48".Sha256()) },
AllowedScopes = { "app.api.weather" }
}
};
}
}
The code above is nothing but a simple static method that returns a List
of test clients. Each client has a unique ClientName
, ClientId
, ClientSecrets
and AllowedScopes
, but all have the same AllowedGrantTypes
set to GrantTypes.ClientCredentials
.
A few things to keep in mind:
- The
OAuth Client Credentials
grant type requiresClientId
andClientSecrets
to authorize access. - The
ClientId
in this example uses a random string that is hashed using theSha256()
extension method built-in to IdentityServer4. You are free to use whatever format for secrets based on your requirements. - The
ClientSecrets
in this example uses aUUID
that is also hashed using theSha256()
extension method. Again, you are free to use whatever format for secrets based on your requirements. - Client applications will use the
ClientId
andClientSecrets
to request aToken
fromIdentityServer4
. - The
AllowedScopes
attribute defines the scopes that a certain client is allowed to access. In this case our configured API resources. Scopes can be used to restrict access to a resource based on read/write permissions. - In
IdentityServer4
scopes are modelled as resources, which come in two flavors:Identity
andAPI
. AnIdentity resource
allows you to model a scope that will return a certain set of claims, while anAPI resource
scope allows you to model access to a protected resource/API. We won't be covering identity resource in this post. - The values we've set are just examples, you would want to change those values to whatever to you want.
Configuring IdentityServer4
Now that we have in-memory test clients and API
resources configured, next is to enable IdentityServer4
. The good thing about IdentityServer4
is it provides various in-memory configurations for us to easily configure IdentityServer
from our configured in-memory objects.
Add the following code at ConfigureServices
method of Startup.cs
file:
public void ConfigureServices(IServiceCollection services)
{
services.AddIdentityServer()
.AddDeveloperSigningCredential()
.AddInMemoryApiResources(Data.ResourceManager.Apis)
.AddInMemoryClients(Data.ClientManager.Clients);
}
The code above registers IdentityServer
and it's dependencies in DI Container
.
Since a signing certificate is required for signing and validating tokens, we've used the AddDeveloperSigningCredential()
provided by IdentityServer4
to automatically generate a signing credential for us to test OAuth
functionality during development and prototyping. In real applications, you should consider using AddSigningCredential()
instead and provide an asymmetric key pair and signing algorithm to sign and validate tokens.
The AddInMemoryApiResources()
and AddInMemoryClients()
middlewares register our in-memory stores for our configured clients and API resources example.
Finally, add the following code at Configure
method of Startup.cs
file:
public void Configure(IApplicationBuilder app)
{
app.UseIdentityServer();
}
That simple! We now have a simple Token Server
for generating JWTs
.
Requesting a Token
IdentityServer4
provides an OIDC
discovery endpoint, which can be used to retrieve metadata about the authorization server including the Token Endpoint
. The discovery endpoint is available via /.well-known/openid-configuration
relative to the base address of your Token Server
. For example, if we run the application locally and perform a GET
request to the following endpoint:
https://localhost:44354/.well-known/openid-configuration
We will then be presented with the following JSON
schema below:
{
"issuer": "https://localhost:44354",
"jwks_uri": "https://localhost:44354/.well-known/openid-configuration/jwks",
"authorization_endpoint": "https://localhost:44354/connect/authorize",
"token_endpoint": "https://localhost:44354/connect/token",
"userinfo_endpoint": "https://localhost:44354/connect/userinfo",
"end_session_endpoint": "https://localhost:44354/connect/endsession",
"check_session_iframe": "https://localhost:44354/connect/checksession",
"revocation_endpoint": "https://localhost:44354/connect/revocation",
"introspection_endpoint": "https://localhost:44354/connect/introspect",
"device_authorization_endpoint": "https://localhost:44354/connect/deviceauthorization",
"frontchannel_logout_supported": true,
"frontchannel_logout_session_supported": true,
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"scopes_supported": [
"app.api.whatever.read",
"app.api.whatever.write",
"app.api.whatever.full",
"app.api.weather",
"offline_access"
],
"claims_supported": [],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token",
"implicit",
"urn:ietf:params:oauth:grant-type:device_code"
],
"response_types_supported": [
"code",
"token",
"id_token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
],
"response_modes_supported": [
"form_post",
"query",
"fragment"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"subject_types_supported": [
"public"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"request_parameter_supported": true
}
We can see that the Token Endpoint
can be accessed at: https://<YOUR-TOKENSERVER-DOMAIN>/connect/token
endpoint. Also notice the app.api.whatever.read
, app.api.whatever.write
, app.api.whatever.full
and app.api.weather
scopes that we have configured earlier are added under the scopes_supported
attribute.
Now, let's do a quick test for requesting a JWT
using the Clients
we've configured by issuing a POST
Http
request in POSTMAN:
POST /connect/token HTTP/1.1
Host: localhost:44354
Content-Type: application/x-www-form-urlencoded
client_id=t8agr5xKt4$3&
client_secret=eb300de4-add9-42f4-a3ac-abd3c60f1919&
grant_type=client_credentials&
scope=app.api.whatever.read app.api.whatever.write
We should be presented with the following Http
response in JSON
format:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlJMUGVHQVhlQkluZFdsdVFwclBBdEEiLCJ0eXAiOiJhdCtqd3QifQ.eyJuYmYiOjE1ODE5MjM5MzQsImV4cCI6MTU4MTkyNzUzNCwiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NDQzNTQiLCJhdWQiOiJhcHAuYXBpLndoYXRldmVyIiwiY2xpZW50X2lkIjoidDhhZ3I1eEt0NCQzIiwic2NvcGUiOlsiYXBwLmFwaS53aGF0ZXZlci5yZWFkIiwiYXBwLmFwaS53aGF0ZXZlci53cml0ZSJdfQ.lWQflS4xUe8hPcPHPjaTenOu7XkSWtsLHY4IGqLxu4AUtM0Ki8XSS2vEaLBfp5rIxgvjSMKbwv2SMJJCOPeB8Ck0L62ohldmAvs2fhiYYNNg4_Oz3ljVfbQz6zdP8xAVc6LKXXVM3Ed8GO_yRAgFDOOCfpiimj81h4QPd8yCrpWvHDihxsvwtCVGBQVQRMEv85fYhWoJ8qeX4sj2sW-dcuLzj-DFsPfqcX-BggXw5O4JVpmQ8QEUNCX1NCkLe8wYhu4GCAwTLK-umhirCSNzBhmAuIV3sXoPWa6VsYK4qJn8OVEOG0vDr8Jd394uauc6NYyxcZsf1qgxPa8-LRm_xA",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "app.api.whatever.read app.api.whatever.write"
}
The value in access_token
attribute is the JWT
. To verify the content and signature inside JWT
, we can use an online tool called jwt.io to decode the value. Here's the decoded metadata:
HEADER:ALGORITHM & TOKEN TYPE
{
"alg": "RS256",
"kid": "RLPeGAXeBIndWluQprPAtA",
"typ": "at+jwt"
}
The result above contains a public key Id (kid
) and other attributes that will be used by client applications to verify the JWT
signature. These attributes are accessible to client applications via the .well-known/openid-configuration/jwks
endpoint in the OpenID Connect
discovery document:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "RLPeGAXeBIndWluQprPAtA",
"e": "AQAB",
"n": "vkz4dwnAFRZ0KmoI4OXFgd53217md67N-egnTKlJQIF4buNjdpLuGzmivTS7_bkaJa4EnRk9O4LA2E59b_q-hDKV34XPpl5FEnr8SOmeNU2BhFDwKxVodqbw4ovkUH3pH5UOcCOIH-_jYBLxwFK2tsJitn_rfDx1bd_W0OnaFqrrgghYMZqf7EYRxCvqrWl6TURtY_zdvWJOWIWiwI7b8D39AxELGbWPpj9oP7sBFmeImqiPnJsnP3626aiB5FMBJeFKF0lwP86x9ZCSFDVMMQcJsrs-Fr6grWzGq-wR7FChfFhZulQ0vH610x323A491yEpYvyZRXjhEqgtOQRthQ",
"alg": "RS256"
}
]
}
The claims above are part of the signing credentials generated by AddDeveloperSigningCredential()
call. The generated developer signing credentials will be persisted in a file called tempkey.rsa
. By default, the file is created at the root directory of our application. The tempkey.rsa
stores the required keys used to sign tokens, allowing for client applications to verify that the contents of the token have not been altered in transit. This involves a private key used to sign the token and a public key to verify the signature.
Note: You don't have to check-in the
tempkey.rsa
file into your source control / git repository, it will be re-created if it is not present.
PAYLOAD:DATA
{
"nbf": 1581923934,
"exp": 1581927534,
"iss": "https://localhost:44354",
"aud": "app.api.whatever",
"client_id": "t8agr5xKt4$3",
"scope": [
"app.api.whatever.read",
"app.api.whatever.write"
]
}
The claims above are the basic metadata for a JWT
.
nbf
stands for not before.exp
stands for expiry.iss
stands for issuer.aud
stands for audience. The resource name in which a client is needed to access.client_id
the client id of the client application requesting the token.scope
the scope in which a client is allowed to access.
Validating a Token
Since we don't have an ASP.NET Core API application that we can use to test yet, we can take advantage of the token introspection endpoint of IdentityServer4 to validate the token. This simulates as if we were an OAuth
resource receiving it from an external client.
The token introspection is available at https://localhost:5001/connect/introspect
endpoint. In POSTMAN, we can use Basic Auth
and set the ApiResource
name as the username and ApiSecret
as the password. These values will then be encoded to Base64
string. Finally, we can set the JWT
as the token parameter. Here's an example POST
Http
request:
POST /connect/introspect HTTP/1.1
Host: localhost:5001
Content-Type: application/x-www-form-urlencoded
Authorization: Basic YXBwLmFwaS53aGF0ZXZlcjphNzVhNTU5ZC0xZGFiLTRjNjUtOWJjMC1mOGU1OTBjYjM4OGQ=
token=eyJhbGciOiJSUzI1NiIsImtpZCI6IlJMUGVHQVhlQkluZFdsdVFwclBBdEEiLCJ0eXAiOiJhdCtqd3QifQ.eyJuYmYiOjE1ODE5MjQ5MTMsImV4cCI6MTU4MTkyODUxMywiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NTAwMSIsImF1ZCI6ImFwcC5hcGkud2hhdGV2ZXIiLCJjbGllbnRfaWQiOiJ0OGFncjV4S3Q0JDMiLCJzY29wZSI6WyJhcHAuYXBpLndoYXRldmVyLnJlYWQiLCJhcHAuYXBpLndoYXRldmVyLndyaXRlIl19.TPhU1v8xeOf7WEu1ApS4Vc5Is_s5ciEYXOrRxk7fEl-5CwAh00psuMtmZ57R6avOmg1kLrcHZQUSNS7UbbIisTcJm6GAdtpuKWvbCV4svagaWKfqntmCJenfYXCJ1ETrTvFYLIW5uSX2sSU8Ve2W8LQOmwlNREvf3QUXFiyODK9S-NO1q2hdVB_cVtVC5EdxSDYbvwJHq-qoz01vL_jdAJl-XBBOqSOGr2htwqsukv6IpvrhPaJzZ6s8oWZ5VOUos1LLc6AeFHmdaTkHqjM34jdmVV2kZ4SycfbM4I6crxTzkK0ou1BCG9e79fsliDrf3OrLmb6rEKSuNZk14s4X_Q
If successful, we’ll receive the following claims in a given token echoed back to us:
{
"nbf": 1581924913,
"exp": 1581928513,
"iss": "https://localhost:5001",
"aud": "app.api.whatever",
"client_id": "t8agr5xKt4$3",
"active": true,
"scope": "app.api.whatever.read app.api.whatever.write"
}
Creating A Simple API Resource
Let's create an ASP.NET Core API that will act as an audience for our Token Server. Add a new project to our exiting solution. Create a new ASP.NET Core Web Application project and select API project template.
Visual Studio scaffolds all the necessary files and dependencies to help you get started building RESTful APIs in ASP.NET Core. The generated template includes a WeatherForecastController
to simulate a simple GET
Http
request using a static data as shown in the code below:
namespace IdentityServer4Demo.WeatherApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly",
"Cool", "Mild", "Warm", "Balmy",
"Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
}
Notice that the Controller
and Action
method isn't protected by default. This means that we should be able to access the /WeatherForecast
endpoint by invoking a GET
Http
request:
Let's run the application to ensure that our API
is working as we expected. You should be presented with the following output in JSON
format:
[
{
"date": "2020-02-18T10:23:51.0316366-06:00",
"temperatureC": 31,
"temperatureF": 87,
"summary": "Mild"
},
{
"date": "2020-02-19T10:23:51.0316788-06:00",
"temperatureC": 36,
"temperatureF": 96,
"summary": "Warm"
},
{
"date": "2020-02-20T10:23:51.0316796-06:00",
"temperatureC": 22,
"temperatureF": 71,
"summary": "Cool"
},
{
"date": "2020-02-21T10:23:51.0316802-06:00",
"temperatureC": -5,
"temperatureF": 24,
"summary": "Cool"
},
{
"date": "2020-02-22T10:23:51.0316805-06:00",
"temperatureC": -18,
"temperatureF": 0,
"summary": "Bracing"
}
]
Great!
Protecting the API with JWT
Now let's secure the WeatherForecast
endpoints. First, install IdentityServer4.AccessTokenValidation
:
PM> Install-Package IdentityServer4.AccessTokenValidation -Version 3.0.1
IdentityServer4.AccessTokenValidation
is an ASP.NET Core authentication handler to validate JWT
and reference tokens from IdentityServer4. Now, let's setup JWT
Authentication Handler with IdentityServer4 by adding the following code at ConfigureServices
method of Startup.cs
file:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options =>
{
options.Authority = "https://localhost:44354";
options.ApiName = "app.api.weather";
});
services.AddControllers();
}
The code above adds Authentication support using "Bearer" as the default scheme. It then configures IdentityServer Authentication handler. The Authority
is the base Url to where your IdentityServer is hosted, in this example our Token Server sits at https://localhost:44354
. The ApiName
should be registered in your IdentityServer as an Audience
. The RequireHttpsMetadata
property is turned off by default and you should turn it on when you deploy the app in production.
When our APIs are decorated with the [Authorize]
attribute, then the requesting clients should provide the access token generated from IdentityServer and pass it as a Bearer Authorization Header
before they can be granted access to our API
endpoints.
Now let's go back the WeatherForecast Controller
and then decorate it with the [Authorize]
attribute:
[ApiController]
[Authorize]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
//...removed for brevity
}
Finally, add the UseAuthentication()
middleware to the pipeline between UseRouting()
and UseAuthorization()
calls:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
//..removed for brevity
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
//..removed for brevity
}
That simple! We now have a simple API Resource
that is protected by JWT Bearer Authentication
scheme.
Testing the API Endpoint
Since this demo is composed of two projects and our ASP.NET Core API application relies on our Token Server
application, we need to make the Token Server
accessible when testing the API Resource
. Typically, we will host or deploy both projects in a web server to be able to connect between them. Luckily, one of the cool features since Visual Studio 2017 is to enable multiple start-up projects. This means that we could run both applications simultaneously within Visual Studio. All you need to do is:
- Right click on the Solution
- Select
Set Startup Projects...
- Select
Multiple Startup Projects
radio button - Select
Start
as the action for both ASP.NET Core Api and Token Server project - Click
Apply
and thenOK
- Now build and run the application.
You will see that our /WeatherForecast
GET
endpoint is now returning an Http
401
Error, and means we are now Unauthorize
to access that endpoint:
In order for us to be authorized, we need a JWT
bearer from our Token Server
. Let's use POSTMAN once again to request a JWT
, and this time we will use the Client Application2
credential since our WeatherForecast
application use the app.api.weather
as the ApiName
which we already have configured in our Token Server
earlier. Here's the sample POST
Http
request for getting a JWT
:
POST /connect/token HTTP/1.1
Host: localhost:44354
Content-Type: application/x-www-form-urlencoded
client_id=3X=nNv?Sgu$S&
client_secret=1554db43-3015-47a8-a748-55bd76b6af48&
grant_type=client_credentials&
scope=app.api.weather
The response should give us the JWT
contained in access_token
attribute:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IlJMUGVHQVhlQkluZFdsdVFwclBBdEEiLCJ0eXAiOiJhdCtqd3QifQ.eyJuYmYiOjE1ODE5NjE1ODgsImV4cCI6MTU4MTk2NTE4OCwiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NDQzNTQiLCJhdWQiOiJhcHAuYXBpLndlYXRoZXIiLCJjbGllbnRfaWQiOiIzWD1uTnY_U2d1JFMiLCJzY29wZSI6WyJhcHAuYXBpLndlYXRoZXIiXX0.tlL_-h8cXxogjOgqvU6tnQwGo9hjAHZS36MIi-M80Q4EqdGSobx1UJM0erd14YolYjtFpAHOr6HajUEzZBePnnkUhWt3zE1Jom6NKMmMB0wWOX8fiEoDtSetrbJNtQ24gOTRRA4mAJobNQtqERYmq0CUCwwcCQ3x7w0igiynZbYTAZPGz_yBDy0XK8Q_SIikD7x9M6OWizvOzcYEobzeVa5Sdt7nE5T4-0vpEllf2-yrF63rxthiZ4WYuT8qFXcFHV1MbzB2skmr66uGyX4Uqs-QZeIt-h0rSQSY6FMprlQf6xwXAk0L8H7SIIWiYFf6u2SHe8CUb1NJsFjZTbed2w",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "app.api.weather"
}
Copy the value of access_token
and then create a new tab in POSTMAN to test out our /WeatherForecast
endpoint. Here's the sample GET
Http
request with the Bearer Authorization
header:
GET /weatherforecast HTTP/1.1
Host: localhost:44380
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IlJMUGVHQVhlQkluZFdsdVFwclBBdEEiLCJ0eXAiOiJhdCtqd3QifQ.eyJuYmYiOjE1ODE5NjE1ODgsImV4cCI6MTU4MTk2NTE4OCwiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NDQzNTQiLCJhdWQiOiJhcHAuYXBpLndlYXRoZXIiLCJjbGllbnRfaWQiOiIzWD1uTnY_U2d1JFMiLCJzY29wZSI6WyJhcHAuYXBpLndlYXRoZXIiXX0.tlL_-h8cXxogjOgqvU6tnQwGo9hjAHZS36MIi-M80Q4EqdGSobx1UJM0erd14YolYjtFpAHOr6HajUEzZBePnnkUhWt3zE1Jom6NKMmMB0wWOX8fiEoDtSetrbJNtQ24gOTRRA4mAJobNQtqERYmq0CUCwwcCQ3x7w0igiynZbYTAZPGz_yBDy0XK8Q_SIikD7x9M6OWizvOzcYEobzeVa5Sdt7nE5T4-0vpEllf2-yrF63rxthiZ4WYuT8qFXcFHV1MbzB2skmr66uGyX4Uqs-QZeIt-h0rSQSY6FMprlQf6xwXAk0L8H7SIIWiYFf6u2SHe8CUb1NJsFjZTbed2w
Now we should be able to see a Http
Status 200
back with the response from /weatherforecast
GET
call:
ApiBoilerPlate AccessTokenValidation Integration
If you are reading this post and haven't tried using ApiBoilerPlate
for building your ASP.NET Core APIs, then head over to the official repository here: https://github.com/proudmonkey/ApiBoilerPlate.
The template uses IdentityServer4
AccessTokenValidation
as well to authenticate and validate access tokens. You can find the code that configures IdentityServer Authentication under Installers/RegisterIdentityServerAuthentication.cs
file. Here’s the code snippet:
services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options =>
{
options.Authority = config["ApiResourceBaseUrls:AuthServer"];
options.RequireHttpsMetadata = false;
options.ApiName = "api.boilerplate.core";
});
The template automatically handles requesting a token for you. At line 61 of Infrastructure/Installers/RegisterApiResources.cs class, you can find that the OIDC Discover Endpoint
is injected as a Singleton
:
services.AddSingleton<IDiscoveryCache>(r =>
{
var factory = r.GetRequiredService<IHttpClientFactory>();
return new DiscoveryCache(config["ApiResourceBaseUrls:AuthServer"], () => factory.CreateClient());
});
This means that everytime you instantiate the HttpClient
object, the discovery endpoint is binded automatically, allowing you to call the token endpoint directly without configuring them all over again. The DiscoveryCache
object pulls the base Url of the Token Server configured in appsettings.json:
"ApiResourceBaseUrls": {
"AuthServer": "https://localhost:5000"
}
Keep in mind that you need to change the value of AuthServer
attribute to where your Token Server is hosted.
Requesting Client Credentials Token
You can see how a token request is being configured at Services/AuthServerConnect.cs class:
namespace ApiBoilerPlate.Services
{
public class AuthServerConnect : IAuthServerConnect
{
private readonly HttpClient _httpClient;
private readonly IDiscoveryCache _discoveryCache;
private readonly ILogger<AuthServerConnect> _logger;
private readonly IConfiguration _config;
public AuthServerConnect(HttpClient httpClient, IConfiguration config, IDiscoveryCache discoveryCache, ILogger<AuthServerConnect> logger)
{
_httpClient = httpClient;
_config = config;
_discoveryCache = discoveryCache;
_logger = logger;
}
public async Task<string> RequestClientCredentialsTokenAsync()
{
var endPointDiscovery = await _discoveryCache.GetAsync();
if (endPointDiscovery.IsError)
{
_logger.Log(LogLevel.Error, $"ErrorType: {endPointDiscovery.ErrorType} Error: {endPointDiscovery.Error}");
throw new HttpRequestException("Something went wrong while connecting to the AuthServer Token Endpoint.");
}
var tokenResponse = await _httpClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = endPointDiscovery.TokenEndpoint,
ClientId = _config["Self:Id"],
ClientSecret = _config["Self:Secret"],
Scope = "SampleApiResource"
});
if (tokenResponse.IsError)
{
_logger.Log(LogLevel.Error, $"ErrorType: {tokenResponse.ErrorType} Error: {tokenResponse.Error}");
throw new HttpRequestException("Something went wrong while requesting Token to the AuthServer.");
}
return tokenResponse.AccessToken;
}
}
}
The code snippet above request an access token from IndentityServer Token endpoint via the DiscoveryCache
by passing the registered ClientId
, ClientSecret
and Scope
in your Token Server. The ClientId
and ClientSecret
are also contained appsettings.json file:
"Self": {
"Id": "api.boilerplate.core",
"Secret": "0a2e472b-f263-43fd-8372-3b13f5acf222"
}
Tip: In production or real application, you should consider storing the secrets and keys in a more secured location like database or cloud vault.
The RequestClientCredentialsTokenAsync()
method will then be called each time you issue an Http
requests via HttpClient
. This process is encapsulated in a custom bearer token DelegatingHandler
class called ProtectedApiBearerTokenHandler. Here’s the code snippet:
namespace ApiBoilerPlate.Infrastructure.Handlers
{
public class ProtectedApiBearerTokenHandler : DelegatingHandler
{
private readonly IAuthServerConnect _authServerConnect;
public ProtectedApiBearerTokenHandler(IAuthServerConnect authServerConnect)
{
_authServerConnect = authServerConnect;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// request the access token
var accessToken = await _authServerConnect.RequestClientCredentialsTokenAsync();
// set the bearer token to the outgoing request as Authentication Header
request.SetBearerToken(accessToken);
// Proceed calling the inner handler, that will actually send the requestto our protected api
return await base.SendAsync(request, cancellationToken);
}
}
}
We can then register the ProtectedApiBearerTokenHandler
as a Transient
service in ConfigureServices
method in Startup.cs
:
services.AddTransient<ProtectedApiBearerTokenHandler>();
That's it! I hope you'll find this post helpful!
Source Code
The source code for this demo is available in my GitHub repository here: https://github.com/proudmonkey/IdentityServer4Demo
No comments:
Post a Comment