Today I wanted to write about how you can integrate OpenID Connect JWT token based authentication in your application stack, Asp.net Core Web API for the back-end and Angular for the front-end. Even if you don't use these technologies, I hope the article will give you some idea of the steps involved.
Most code I have included here comes from the github repo that I have reference at the end of the article.
JWT
JWT (JSON Web Token) is a critical piece in OpenID Connect. The client application (such as an Angular SPA), obtains a JWT access token from the authentication server using one of the pre-defined OAuth flows. It then passes the token with requests to the Resource Server (such as Asp.net Core Web API). The resource server evaluates the token and accepts/rejects the request based on it. Given the importance of JWT, Let's start with a quick introduction of JWT tokens. The JWT Introduction explains it very well, but here are some of the points as summary.
JWT is a self contained token, meaning it carries it's own signature to verify it's validity. This means that to validate a JWT token, the resource server does not need to query the Auth server asking if the token is valid. It just needs to validate the signature that is carried within the token. Here's a sample JWT token:
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0ZWFSLVJTS2QwdHhXejZSWnR2TVROazByVkVncFR2Qnd0QlhLbGlKMVF3In0.
eyJleHAiOjE2MjQzNzYwODIsImlhdCI6MTYyNDM3NTQ4MiwiYXV0aF90aW1lIjoxNjI0Mzc0Mjc2LCJqdGkiOiJmZjQ2YzJmNy1hNjkxLTQzNTQtOTUzNC1lODY4YTA4YzkzNGQiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvQXV0aERlbW9SZWFsbSIsImF1ZCI6ImF1dGgtZGVtby13ZWItYXBpIiwic3ViIjoiYjEyMWEzNDMtYTk1OC00MTJhLTg3YzAtNzFhNGE3NmRmNTBhIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiYXV0aC1kZW1vLXNwYSIsIm5vbmNlIjoiNjgzNTY3YWVmZjk5NDcyYmRlNzJiZDgyMDk4MGE0NTY2N3RSQWRrMTgiLCJzZXNzaW9uX3N0YXRlIjoiNTMyZDExZWEtZTU1My00Mjc2LWFiMDItYThkNjRjOWU2OWE5IiwiYWNyIjoiMCIsInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IkRlbW8gVXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6ImRlbW8udXNlciIsImdpdmVuX25hbWUiOiJEZW1vIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZW1haWwiOiJkZW1vLnVzZXJAZW1haWwuY29tIn0.
U3qv-ujxq5axhLYeEON4ofDmd2CH_RLDhY3KBK8gNJkAYIx3dhCMI-4mNjIPxkpXjXdF1Ci7fX2zM9AN8_d2nVhYQB7dusdvjxRAfzu_IzAPhl4hZXNxEIJYd2f6KBVU_gnSKgJyEi5LJ89blYIllbyN5KfPke_DIZgL3CfhUiAGqE5eW7UY1weOTjcGsV29u5vv_FcONmk2z_uTDH9qN7g-xTVkEv3tr-u7osK4T8fwoxWA62TlTyxefr_ZsDDyy3nGCL_YhDTdzqASs5_Xc60vaP0x3BmdAHXM4-p0xgei6qOv9g4FYy_3u1DUAnoXY6g-Nls-MVs1K18f8H2ZfA
JWT tokens are separated into three parts by dots (.):
JWT Header
The first part is the JWT header. This is just Base64Url encoded, not encrypted, which means anyone can decode it, for example using an online tool such as this one. Therefore, it does not contain any secret, which is important to note. If you put the first part of the token (separated by .) in the tool and decode you get the following JSON:
{
"alg": "RS256",
"typ": "JWT",
"kid": "4eaR-RSKd0txWz6RZtvMTNk0rVEgpTvBwtBXKliJ1Qw"
}
Other than the obvious typ (type) JWT, the header tells us that the token is signed using RS256 algorithm and a kid (Key ID) of the token. Both of these come into play when we need to validate the token with the signature. More on this below.
JWT Payload
The second part of the JWT token is the payload which is also just Base64Url encoded, which means anyone can decode it. (Online decoding tool). If you copy the 2nd part of the token above decode it you get the following JSON payload:
{
"exp": 1624376082,
"iat": 1624375482,
"auth_time": 1624374276,
"jti": "ff46c2f7-a691-4354-9534-e868a08c934d",
"iss": "http://localhost:8080/auth/realms/AuthDemoRealm",
"aud": "auth-demo-web-api",
"sub": "b121a343-a958-412a-87c0-71a4a76df50a",
"typ": "Bearer",
"azp": "auth-demo-spa",
"nonce": "683567aeff99472bde72bd820980a45667tRAdk18",
"session_state": "532d11ea-e553-4276-ab02-a8d64c9e69a9",
"acr": "0",
"scope": "openid email profile",
"email_verified": true,
"name": "Demo User",
"preferred_username": "demo.user",
"given_name": "Demo",
"family_name": "User",
"email": "demo.user@email.com"
}
Each of these properties of the payload is called claims. There are some standard claims such as iss (issuer), aud (audience) etc. Some of these are mandatory claims such as the exp (expiry) which must be present. I usually go to the JWT Spec here to look up the standard claims and what they mean, the document is easy to read and understand.
Other than the standard claims the payload may have custom claims. In the above example email_verified and preferred_username are such custom claims handing out by Keycloak with the out of the box configuration.
Important to note here again, there are no secrets in the payload. But for an access token, it should contain enough information for the resource owner (Web API backend in our case) to evaluate if access to data should be granted.
The scope
claim is a special one that needs a bit of attention. A scope is a grouping claims and it's requested by the client app. In the example above given_name
and family_name
claims are present because the client app requested the profile
scope. Just because the client requested a scope doesn't mean the auth server will provide the claims for that scope in the payload, it depends on many factors. For example the scope maybe configured on the authorisation server in a way that it requires to present consent screen to the user and will only be provided if user has given consent. The scope
claim in the JWT payload basically tells us which scopes have been provided in the payload, in this case openid
(which is the standard scope for OpenID Connect, must be preset), email
and profile
.
It's a very common practice to have custom resource oriented scopes. Let's elaborate a little further with an example. Let's say you are working with an Auth server that logs in tradies to use your application. The auth server provides a trade_license
scope, which adds claims trade_license_number
and trade_license_expiry
to the token. When the clien app requests for the trade_license
scope, user is asked during login:
This application wants to access your trade license information. Do you accept? [yes | no].
If user selects no, the trade_license
scope is not provided as part of the scope
claim, trade_license_number
and trade_license_expiry
claims are not provided with the payload. If user answers yes these claims are provided.
In asp.net core you can enable policy based authentication allowing you to check wether a scope exists or if a claim has an expected value. More on that later.
JWT Signature
The third part of the JWT token is the signature which ensures that the token has originated from the auth server and it has not been tinkered with. The signature is constructed with the first two parts and a secret. As a result the generated signature is only valid for the header and payload data of the token, which ensures that no one can change the content of the header and payload. The use of secret to create the token ensures that only the server can generate a signature. There are two types of algorithms for creating the token signature, symetric and asymetric.
Symetric Algorithms
The symetric algorithoms use a secret, along with the first two parts of the token to generate the signature. To validate the token, the receiver of the token would then need to use the same secret. The fact that the secret has to be known by the auth server as well as the resource server poses a few limitations such as:
- Automatically changing the secret is not easy. If the secret is exposed and you need to change it, you'd have to do that on all the resource servers.
- You cant use the secret on "untrusted" applications where the source code is open or can easily be reversed-engineered, such as a browser app or a mobile app.
Asymetric Algorithms
Because of the limitations above, usually asymatric algorithoms are used to create JWT signature. Asymetric algorithms use a private key to sign the token, and exposes a public key that can be used to verify the token. So the private key used to create the token is never exposed. The public key is available to retrieve from an .well-known
endpoint called jwks_uri
. Since the resource server is not dependent on a shared key, this enables a key-rotation mechanism, ensuring that the private-public keys are rotated regularly to ensure extra security against key leaks.
Resource Server - Validating the Token
Now let's see what we need to do to validate that token we've been provided on the resource server. You'll need to do these steps below:
- Check to see if the token has been expired.
- Check the
iss
(issuer) claim to ensure that the token has been issued by the right auth server. - (Optional) Check the
aud
(audience) claim to ensure that the resource server is one of the audiences. - Check to see if the token has a valid signature.
- Download the public key for from
jwks_uri
. - Validate the token with the public key.
- Download the public key for from
- (Optional) Check required scopes are present.
When using Asp.net core (3.1/5) we're just going to use the provided middleware that does it all. This is how you can configure it as a minimum:
services
.AddAuthentication()
.AddJwtBearer(x =>
{
x.MetadataAddress = "http://keycloak:8080/auth/realms/AuthDemoRealm/.well-known/openid-configuration";
x.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = "auth-demo-web-api"
};
});
And that's it! MetadataAddress
is set to the .well-known
endpoint. The middleware can retrieve the following required properties from the .well-known
edn-point:
- The issuer
- The
jwks_uri
from where it needs retrieve the public key to validate the token signature.
Now that you have a valid token configuration you can configure claims based authorisation for default authorisation policy:
services.AddAuthorization(o =>
{
o.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("email_verified", "true")
.Build();
});
What we are saying there in effect is that the default Authorize
attribute on a controller or action will return unauthorised if the token doesn't have the claim email_verified
with value true
. Here's an example of a controller that's using the default plicy to authorise:
[ApiController]
[Route("[controller]")]
[Authorize]
public class EamilController : ControllerBase
{
// ...
}
You could have different named policies as well along with the default policy:
services.AddAuthorization(o =>
{
o.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("email_verified", "true")
.Build();
var tradieAuthorizerPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("trade_license_number")
.Build();
o.AddPolicy("TradieOnly", tradieAuthorizerPolicy);
});
You could then apply the named policy by setting Policy
value of the Authorization
attribute. In the example below anyone can post a job, but only tradies, with a trade_license_number as per the policy configuration above, are authorised to apply:
[ApiController]
[Route("[controller]")]
[Authorize()]
public class JobsController : ControllerBase
{
[Authorize( Policy = "TradieOnly")]
public async Task Apply(JobApplication jobApplication)
{
// ...
}
public async Task PostJob(Job job)
{
// ...
}
}
Checkout the Authorisation section for Asp.net core for more information on claims based and policy based authorisation.
Client App - Obtaining the Token
There are a number of flows defined in OAuth2 to obtain the access token. For untrusted clients it's Authorisation Code Flow with Proof Key for Code Exchange (PKCE). In a nutshell these are the steps from when user clicks login button to obtaining the access token:
- Client app creates a random
Code Verifier
and aCode Challenge
hash from it. - Redirects to the auth server's authorisation endpoint. This endpoint is usually published in
.well-known
JSON for the auth server. The client app passes the following parameters as a minimum with the redirect:- Redirect URL: The URL where the auth server will redirect back to once the authorisation is complete (successful or failed)
- Code Challenge: This is part of the PKCE flow. Later we pass in the code verifier for the challenge when we request access code. see below.
- The server performs N number of steps as required to authenticate the user. These steps usually include:
- Username+password prompt
- Password change prompt when password has expired
- Accept new terms and conditions prompt
- Setting up some another MFA, etc
- At the end of it all, once the user is authorized, the server redirects back to the
Redirect URL
we passed in Step 2. The server provides anAuthorisation Code
with the redirect, which is a one time code that can be used by the client app to obtain theAccess Token
. - The client app requests for the access token at the
/token
endpoint using the providedAuthorization Code
and theCode Verifier
created in step 1. The endpoint to obtain the token is usually published in the.well-known
JSON.
If all is successful, the client app will have an access token that it can be pass in with the authorization
header for restricted resources.
Even tough all the steps above maybe easy to write by yourself, I recommend against it. My suggestion is that you use a client library that does these steps for you. I always prefer using code written by the experts, well tested and possibly improved over time based on other users experiences, specially when it comes to security. On the same note avoid libraries that are not quite OpenID Connect compatible, not easy to configure or requires you to do some steps manually. For example the MSAL library, avoid if you are implementing an OIDC client.
If you are using Angular, I highly recommend using angular-auth-oidc-client npm package. The library is OpenID Certified. Below is my client configuration and angular-auth-oidc-client
does all of the above steps to ensure the the access token is available:
oidcConfigService.withConfig({
authWellknownEndpoint: 'http://localhost:8080/auth/realms/AuthDemoRealm/.well-known/openid-configuration',
redirectUrl: `${window.location.origin}/home`,
clientId: 'auth-demo-spa',
scope: 'openid profile email',
responseType: 'code',
triggerAuthorizationResultEvent: true,
postLogoutRedirectUri: `${window.location.origin}/home`,
startCheckSession: false,
postLoginRoute: '',
unauthorizedRoute: '/unauthorized',
logLevel: LogLevel.Debug
});
The first 5 lines in the configuration are really the most important ones:
- authWellKnownEndpoint: We're setting the
.well-known
URI here. The client library should know the following important endpoints from here:- authorisation URL where it should redirect to to start the auth process
- Token endpoint where it can request the access token using the authorisation code (at the end of a successful flow).
- redirectUrl: The URL where the auth server will redirect back to once authentication is successful. Usually you need to configure this on the Auth server as well, otherwise you'll get an error. clientId: You must configure a client on the auth server and pass in the configured client's client-id here.
- scope: Scopes requested. You must have
openid
for OpenID Connect claims. - responseType: Should be set to
code
for Authorisation Code Flow.
Below are my login()
and logout()
methods hooked to the login/logout buttons.
login() {
this.oidcSecurityService.authorize();
}
logout() {
this.oidcSecurityService.logoff();
}
The library ensures that the access code is refreshed using refresh token automatically. Below is the code that gets the access token that is already available if the user has logged in:
let apiUrl = `${environment.baseApiUrl}/weatherforecast`;
let accessToken = this.oidcSecurityService.getToken();
let headers = {};
if(accessToken)
headers['authorization'] = `Bearer ${accessToken}`;
this.data = await this.httpClient
.get(apiUrl, {
headers: headers
})
.pipe(take(1))
.toPromise();
Authentication Responsibility Separation
Developing your application layers to work with OpenID Connect as in the example above means that you are separating the authentication responsibility from your application code. For example, if you have configured Azure B2C for your auth provider, you can just change the configuration on your asp.net core app:
services
.AddAuthentication()
.AddJwtBearer(x =>
{
x.MetadataAddress = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration";
x.TokenValidationParameters = new TokenValidationParameters
{
ValidAudience = "auth-demo-web-api"
};
});
... and your Angular app as:
oidcConfigService.withConfig({
authWellknownEndpoint: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
redirectUrl: `${window.location.origin}/home`, // need to configure
clientId: 'auth-demo-spa', // need to configure
scope: 'openid profile email',
responseType: 'code',
triggerAuthorizationResultEvent: true,
postLogoutRedirectUri: `${window.location.origin}/home`,
startCheckSession: false,
postLoginRoute: '',
unauthorizedRoute: '/unauthorized',
logLevel: LogLevel.Debug
});
... and it now works with Microsoft Login. If want to use AWS Cognito as your auth provider, set-up a user-pool, find out how to get the .well-known
endpoint, configure the server and the client and you're good to go.
Conclusion
There are a number of good libraries out there to help you integrate the authentication mechanism for whatever framework you are using and you should resort to these libraries to do the heavy lifting for you. Because the last thing you want is to leave a security whole trying to write it all by yourself. Oh and stay away from MSAL.
Source Code
Here's the github repo from one of my earlier posts that has Keycloak as auth server, Asp.net Core Web API as resource server and Angular client app. This should give you a starting point if you would like to play with even some other auth server configurations:
Kayes-Islam / keycloak-demo
Top comments (0)