Starting from this chapter?
Clone the application repo and check out the build-api
branch:
git clone git@github.com:auth0-blog/menu-api-express-ts.git \
menu-api \
--branch build-api
Make the project folder your current directory:
cd menu-api
Then, install the project dependencies:
npm i
Finally, create a .env
hidden file:
touch .env
Populate .env
with this:
PORT=7000
Run the project by executing the following command:
npm run dev
In the Build an API with Node.js and TypeScript Tutorial, you went over how to build an API using Express, a Node.js web framework, and TypeScript, an open-source language that builds on JavaScript. You learned how to define data models, create a data service, and quickly build modular endpoints.
Now, this tutorial will show you how to secure the API using Auth0. To see your API in action, you'll use a production client called "WHATABYTE Dashboard," which is inspired by the sleek web player from Spotify:
One of the requirements for this project is that only authorized users can write records to the store. To quickly and securely achieve that, you can use Auth0 to manage your application's user credentials.
Set Up an Authorization Service
With Auth0, you can manage the authorization requirements of an application stack easily. To start, you need to create a free Auth0 account if you don't have one yet.
Auth0 is a flexible, drop-in solution to add authentication and authorization services to your applications. Your team and organization can avoid the cost, time, and risk that comes with building your own solution to authenticate and authorize users. We offer tons of guidance and SDKs for you to get started and integrate Auth0 in your stack easily.
After you create your account, you'll create an Auth0 Tenant, which is a container that Auth0 uses to store your identity service configuration and your users in isolation — no other Auth0 customer can peek into or access your tenant. It's similar to you being a tenant in an apartment building. Auth0 looks after the building while the apartment is all yours to live in and customize. However, each apartment is fully isolated (no windows, soundproof walls, etc.) so that neighbors can't intrude on your privacy.
After creating your tenant, you need to create an API register with Auth0, which is an API that you define within your Auth0 tenant and that you can consume from your applications to process authentication and authorization requests.
After creating your account, head to the APIs section in the Auth0 Dashboard and hit the Create API button.
Then, in the form that Auth0 shows:
Add a Name to your API:
Menu API
.Set its Identifier to
https://menu-api.example.com
.Leave the signing algorithm as
RS256
as it's the best option from a security standpoint.
Identifiers are unique strings that help Auth0 differentiate between your different APIs. We recommend using URLs as they facilitate predictably creating unique identifiers; however, Auth0 never calls these URLs.
With these values in place, hit the Create button.
Your API needs some configuration variables to identity itself with Auth0: an Audience and a Domain value. The best place to store these values is within the .env
file of your project.
Open .env
and add the following keys to it:
PORT=7000
AUTH0_AUDIENCE=
AUTH0_DOMAIN=
Head back to your Auth0 API page, and follow these steps to get the Auth0 Audience:
Click on the "Settings" tab.
Locate the "Identifier" field and copy its value.
Paste the "Identifier" value as the value of
AUTH0_AUDIENCE
in.env
.
Now, follow these steps to get the Auth0 Domain value:
- Click on the "Test" tab.
- Locate the section called "Asking Auth0 for tokens from my application".
- Click on the cURL tab to show a mock
POST
request. - Copy your Auth0 domain, which is part of the
--url
parameter value:tenant-name.region.auth0.com
. - Paste the Auth0 domain value as the value of
AUTH0_DOMAIN
in.env
.
Tips to get the Auth0 Domain
The Auth0 Domain is the substring between the protocol,
https://
and the path/oauth/token
.The Auth0 Domain follows this pattern:
tenant-name.region.auth0.com
.The
region
subdomain (au
,us
, oreu
) is optional. Some Auth0 Domains don't have it.Click on the image above, please, if you have any doubt on how to get the Auth0 Domain value.
Restart the server so that Express can recognize the changes you just made to .env
. Stop the running process and execute npm run dev
once again.
Create Authentication Middleware
You'll rely on a middleware function to protect an Express API endpoint. Express will execute an authorization middleware function before it executes the callback function of the controller that handles the request.
You can use two patterns to integrate your endpoints with the authorization middleware function.
The first option is to "inject" an authorization middleware function in the controller as follows:
itemsRouter.post(
"/",
authorizationFunction,
async (req: Request, res: Response) => {
// Controller logic...
}
);
Here, Express calls authorizationFunction()
before the route handler function of itemsRouter.post
. In turn, the business logic within authorizationFunction
can perform two tasks:
(a) invoke the next function in the middleware chain, the router handler function, if it can determine that the user has the authorization to access the resource or,
(b) close the request-response cycle by responding with a 401 Unauthorized
message, which prevents your API from executing the route handler.
The approach of adding authorization middleware by controller gives you granular and low-level control of the authorization flow. However, it can be tedious to inject the authorization middleware function per controller if you have many of them.
As an alternative, you can separate the public controllers from the protected controllers using the authorization middleware as a boundary between groups. For example, within an Express router, you could do the following:
// Public API endpoints
itemsRouter.get(...);
// Protected API endpoints
itemsRouter.use(authorizationFunction);
itemsRouter.post(...);
itemsRouter.put(...);
itemsRouter.delete(...);
As such, client applications can access the GET
endpoint without presenting any "proof of authorization" — it is a public endpoint.
However, client requests can only access endpoints that you define after your application mounts authorizationFunction
into itemsRouter
if authorizationFunction
can determine that the client making the endpoint request has the authorization to access it. For this API, Auth0 provides the proof of authorization mentioned in the form of a JSON Web Token (JWT) called an access token.
A JWT defines a compact and self-contained way to transmit information between parties as a JSON object securely. This information can be verified and trusted because it is digitally signed, making JWTs useful to perform authorization.
Once the user logs in using a client application, Auth0 provides the client with an access token that defines the resources that the client has permission to access or manipulate with that token. The access token defines what users can do in your API in the JSON object it encapsulates. As such, the client must include the access token with each subsequent request it makes to a protected API endpoint.
Interested in getting up-to-speed with JWTs as soon as possible?
Download the free ebookYou'll use the partition approach for this application as you need to protect all the endpoints that write data to the store.
Install authorization dependencies
To create your authorization middleware function, you need to install these two packages:
npm i express-jwt jwks-rsa
Here's what these packages do for you:
express-jwt
: Validates the authorization level of HTTP requests using JWT tokens in your Node.js application.jwks-rsa
: A library to retrieve RSA signing keys from a JWKS (JSON Web Key Set) endpoint.
Since you are working on a TypeScript project, you also need the type definitions for these packages; however, only the express-jwt
package is available in the @types
npm namespace:
npm i -D @types/express-jwt
The helper function you need from the
jwks-rsa
package is simple and doesn't require strong typing.
Next, create a file to define your authorization middleware function:
touch src/middleware/authz.middleware.ts
Populate src/middleware/authz.middleware.ts
as follows:
// src/middleware/authz.middleware.ts
import jwt from "express-jwt";
import jwksRsa from "jwks-rsa";
import * as dotenv from "dotenv";
dotenv.config();
export const checkJwt = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`
}),
// Validate the audience and the issuer.
audience: process.env.AUTH0_AUDIENCE,
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
algorithms: ["RS256"]
});
When you call the checkJwt
function, it invokes the jwt
function, verifying that any JSON Web Token (JWT) present in the request payload to authorize the request is well-formed and valid. Auth0 determines the validity of the JWT. As such, you pass the jwt
function some variables to help it contact Auth0 and present it with all the JWT information it needs:
The
audience
andissuer
of the JWT. You have defined these values in your.env
file. Express has loaded into this module usingdotenv.config()
.The
algorithms
used to sign the JWT.The
secret
used to sign the JWT.
To obtain the secret, you need to do some additional work: you use the expressJwtSecret
helper function from the jwks-rsa
library to query the JSON Web Key Set (JWKS) endpoint of your Auth0 tenant. This endpoint has a set of keys containing the public keys that your application can use to verify any JSON Web Token (JWT) issued by the authorization server and signed using the RS256 signing algorithm.
The checkJwt
function implicitly receives the request, req
, and response, res
, object from Express, as well as the next()
function, which it can use to invoke the next middleware function in the chain.
All that's left to do is mount the checkJwt
middleware function before you mount your itemsRouter
write endpoints.
Open src/items/items.router.ts
and import checkJwt
under the Required External Modules and Interfaces
section:
/**
* Required External Modules and Interfaces
*/
import express, { Request, Response } from "express";
import * as ItemService from "./items.service";
import { BaseItem, Item } from "./item.interface";
import { checkJwt } from "../middleware/authz.middleware";
Then, under the Controller Definitions
section, locate the definition of the POST items
endpoint, and right above it, add the following code to mount the authorization middleware, itemsRouter.use(checkJwt)
:
/**
* Controller Definitions
*/
// GET items
itemsRouter.get(...);
// GET items/:id
itemsRouter.get(...);
// ✨ New! Mount authorization middleware
itemsRouter.use(checkJwt); // 👈 👀
// POST items
itemsRouter.post(...);
// PUT items/:id
itemsRouter.put(...);
// DELETE items/:id
itemsRouter.delete(...);
To test that Express is protecting your write endpoints, issue the following requesting in the terminal:
curl -X POST -H 'Content-Type: application/json' -d '{
"name": "Salad",
"price": 499,
"description": "Fresh",
"image": "https://cdn.auth0.com/blog/whatabyte/salad-sm.png"
}' http://localhost:7000/api/menu/items -i
The server replies with an HTTP/1.1 401 Unauthorized
response status, and the message No authorization token was found
, confirming that your write endpoints are protected. To access them, you need a JWT issued by Auth0. The fastest way to get that token is to use a client just like any of your users would.
Register a Client Application with Auth0
You need a client application to simulate an end-user interaction with your API and see its security in action. To make that simulation more fun and engaging, you'll use the WHATABYTE Dashboard, a demo client application that lets you manage items for a restaurant menu. You'll create a user with Auth0, log in, and access pages that make requests to your API endpoints under the hood.
For that end-user interaction to happen, you'll need to create a Single-Page Application register with Auth0. This register will provide you with the configuration values that you need to connect the demo client application with Auth0, namely the Auth0 Domain and Auth0 Client ID. Once configured, the client application can communicate with the Auth0 authentication server and get access tokens for your logged-in users.
The process of creating an Auth0 Single-Page Application register is straightforward:
Open the Auth0 Applications section of the Auth0 Dashboard.
Click on the Create Application button.
Provide a Name value such as WHATABYTE Demo Client.
Choose Single Page Web Applications as the application type.
Click on the Create button.
A new page loads with details about your Auth0 application register. Click on its Settings tab to access its configuration values.
Next, visit https://dashboard.whatabyte.app/
to open the WHATABYTE Dashboard demo client application.
If you are not on the Auth0 Demo Settings page, click on the "Settings" tab from the left-hand navigation bar and then click the "Modify" button.
Enable the authentication features of the demo application. Then, use the configuration values present in your Auth0 application "Settings" page to fill the values of Auth0 Domain and Auth0 Client ID in the demo settings form:
For the value of Auth0 API Audience use https://menu-api.example.com
, which is the Identifier of the MENU API you registered with Auth0 earlier in the tutorial.
For the value of Auth0 Callback URL use https://dashboard.whatabyte.app/home
. You'll learn what how Auth0 uses this callback value in the next section.
Click the Save button below the form. The WHATABYTE Dashboard is a client to your API server. To test this connection, click on the Menu tab and observe how it populates with the menu items you defined in your API store.
Connect a client application with Auth0
Head back to the Settings tab of your Auth0 application register page and update the following fields:
Allowed Callback URLs
Use the value of Auth0 Callback URL from the Auth0 Demo Settings form, https://dashboard.whatabyte.app/home
.
After a user authenticates, Auth0 only calls back any of the URLs listed in this field. You can specify multiple valid URLs by comma-separating them (typically to handle different environments like QA or testing). Make sure to specify the protocol, http://
or https://
; otherwise, the callback may fail in some cases.
Allowed Web Origins
Use https://dashboard.whatabyte.app
.
A client application will make requests under the hood to an Auth0 URL to handle authentication requests. As such, you need to add your the application's origin URL to avoid Cross-Origin Resource Sharing (CORS) issues.
Allowed Logout URLs
Use https://dashboard.whatabyte.app/home
.
This field holds a set of URLs that Auth0 can redirect to after a user logs out of your application. The default configuration of the demo client uses the provided value for redirecting users.
With these values in place, you can scroll to the bottom of the "Settings" page and click on the Save Changes button.
Sign In
In the demo client, click on the Sign In button. The client will redirect you to the Auth0 Universal Login page to log in or sign up. Since this may be the first user you are adding to Auth0, go ahead and click on the Sign Up link at the bottom of the form. Then, provide an email and password to register a new user.
Once you sign in, the user interface of the demo client changes:
The Sign In button becomes a Sign Out button.
You can find a user tab below the Sign Out button.
Click on the user tab to see a profile page with your name or email as the title and your profile picture — if you signed in with Google:
The demo client caters to three types of users:
Unauthenticated visitors: any visitor who has not logged in — some literature may refer to this type of user as "guest" or "anonymous".
Authenticated users: any visitor who successfully logs in.
Admin users: any authenticated user with the
menu-admin
role.
The end-goal of this tutorial is to use the menu-admin
role and its associated permissions as access control artifacts. The plan is to only allow admin users to create, update, and delete menu items in the WHATABYTE Dashboard. In the Role-Based Access Control (RBAC) section of this tutorial, you'll create the menu-admin
role, associate permissions with it, and assign it to a new user that you'll create through the Auth0 Dashboard.
However, you'll start with protecting your API write endpoints against unauthenticated visitors.
Experiment with the demo client:
Add items by clicking on the Add Item button located at the top-right corner of the "Menu" page.
Click on items and try to edit them or delete them.
You can do any read or write operations right now.
Security Exercise: Test your endpoint protection
Log out from the demo application.
Click on the Settings tab on the left-hand navigation bar of the demo client. Then, click on the Modify button.
The "Auth0 Demo Settings" page loads up. Disable the authentication features:
Click on the Save button.
Once the demo application loads again, click on the Menu tab. You'll notice that the Add Item button is now visible. In this mode, the demo client lets you access UI elements that make requests to your API write endpoints as an unauthenticated visitor. As such, those requests won't include an access token. If your API security is working correctly, it should reject those requests.
Click on the Add Item button to open a pre-populated form and click on the Save button. You'll get an error, No authorization token was found
:
Click on the Cancel button in the "Add Menu Item" page. The "Menu Items" loads again. Click on the "Burger" item and try to edit it or delete it.
Those two actions will also fail:
You have tested that Express is guarding your create, update, and delete endpoints correctly, concluding this short exercise.To continue with the rest of this tutorial, re-enable the demo client authentication features. Click on the Settings tab and click on the Modify button. The "Auth0 Demo Settings" page loads up. Enable the authentication features, fill out the necessary value, and click on the Save button.
Configure Role-Based Access Control (RBAC)
Your API server is protecting your write endpoints, but any authenticated user can modify the menu items. This scenario is far from ideal as you don't want regular customers to change an item's price, for example. As such, you need a mechanism to limit access to your API resources and demonstrate that being authenticated is not the same as being authorized. However, if all that you want for now is to restrict API access to logged-in users, you've achieved that, and you are done.
A straightforward way to implement access control is to create a set of write permissions and bundle them in a menu-admin
role, which you assign only to select users. Thus, only select users are authorized to modify resources in your API. Consequently, your server must enforce role verification on each API write endpoint to prevent unauthorized access.
To re-iterate: being authenticated won't be enough to being authorized to write data to the store.
The practice described above is known as Role-Based Access Control (RBAC), which you can implement quickly for your API using the Auth0 Dashboard. You can implement RBAC and enforce it on your server as follows:
On the Auth0 side
Create permissions for the Menu API you created earlier.
Create a role called
menu-admin
.Assign permissions from the Menu API to the
menu-admin
role.Assign the
menu-admin
role to a user.Add the
menu-admin
role permissions to the access token created for users with the role when they sign in.
On the server side
Define the
menu-admin
role permissions in a TypeScriptenum
.Define the permissions required to access an endpoint by passing permission values as arguments to an authorization middleware function, which Express calls before the endpoint route handler.
Implement the authorization middleware function to determine authorization by comparing the permissions required by the endpoint against the permissions present in a user's access token.
As you can see, implementing authorization is a complex process that involves many steps. Any errors or omissions in the process of implementation can leave an API at risk of being compromised. Thankfully, you can delegate the creation and maintenance of permissions, roles, and users to Auth0 and focus only on enforcing authorization on your server.
With the plan clearly outlined, let's get started.
Define permissions for the API
Open the APIs page from the Auth0 Dashboard and select the Menu API that you created earlier.
In the Menu API page, click on the Permissions tab and create three permissions by filling each row as follows (the + Add button adds a new row):
create:items
: Create menu itemsupdate:items
: Update menu itemsdelete:items
: Delete menu items
Next, you need to configure Auth0 to enforce role-based access control (RBAC) authorization for the Menu API. Click on the Settings tab and scroll down until you see the RBAC Settings section. Use the toggle button next to Enable RBAC to turn it on, which enforces Auth0 to evaluate RBAC authorization policies during the login transaction of a user.
Next, enable Add Permissions in the Access Token to add a permissions
property to the access token created by Auth0 when a user logs in. The permissions
property is a key-value pair known as a token claim. The presence of this claim is critical for the implementation of RBAC in your API server.
Once you enable these options, make sure to click on the Save button.
Create roles
Open the Roles page from the Auth0 Dashboard and click on the Create Role button. Fill out the pop-up form as follows:
Name:
menu-admin
Description: Create, update, and delete menu items.
Once done, click the Create button to complete the creation of the role.
Now, you need to associate the permissions you've created with this role, mapping it to your API's resources. Click on the Permissions tab of the role page. Once there, click on the Add Permissions button.
In the dialog that comes up, choose the Menu API from the dropdown box and select all the boxes in the Scopes section. Once that's done, click on the Add permissions button. You are back to the menu-admin
role page, which now lists all its associated permissions.
A scope is a term used by the OAuth 2.0 protocol to define limitations on the amount of access that you can grant to an access token. In essence, permissions define the scope of an access token.
Get user roles
Auth0 attaches the menu-admin
role permissions as a claim to the access token, but not the role itself. The demo client application needs this information as it renders its UI conditionally based on the user role. To include the user role as a claim in the tokens that Auth0 sends to the client, you can use Auth0 Rules.
When a user logs in successfully to your application, the Auth0 authorization server sends two tokens to the client:
Access token
After a user successfully authenticates and authorizes access, the client application receives an access token from the Auth0 authentication server. The client passes the access token as a credential whenever it calls a protected endpoint of the target API. This token informs the server that the client is authorized to access the API. Through its permissions claim, the access token tells the server which actions the client can perform on which resources.
ID token
The ID Token is a JSON Web Token (JWT) that contains claims representing user profile attributes like name or email, which are values that clients typically use to customize the UI.
Using Auth0 Rules, you can add to each of these tokens a new claim, representing the roles assigned to a user.
What are Auth0 Rules?
Auth0 Rules are JavaScript functions that execute when a user logs in to your application. They run once the authentication process is complete, and you can use them to customize and extend Auth0's capabilities. For security, your Rules code executes in a sandbox, isolated from the code of other Auth0 tenants.
You can create Auth0 Rules easily using the Auth0 Dashboard. Follow these steps to create a rule that adds user roles to tokens:
Open the Rules page from the Auth0 Dashboard.
Click on the Create Rule button.
Click on the Empty Rule option.
Provide a Name to your rule, such as "Add user roles to tokens".
Next, replace the content of the Script section with the following function:
function(user, context, callback) {
const namespace = 'https://menu-api.example.com';
if (context.authorization && context.authorization.roles) {
const assignedRoles = context.authorization.roles;
if (context.idToken) {
const idTokenClaims = context.idToken;
idTokenClaims[`${namespace}/roles`] = assignedRoles;
context.idToken = idTokenClaims;
}
if (context.accessToken) {
const accessTokenClaims = context.accessToken;
accessTokenClaims[`${namespace}/roles`] = assignedRoles;
context.accessToken = accessTokenClaims;
}
}
callback(null, user, context);
}
- Click the Save Changes button.
What's this rule doing?
When the user successfully authenticates, this rule function executes, receiving three parameters:
user
: an object returned by the identity provider (such as Auth0 or Google) that represents the logged-in user.context
: an object that stores contextual information about the current authentication transaction, such as the user's IP address or location.callback
: a function to send modified tokens or an error back to Auth0. You must call this function to prevent script timeouts.
function(user, context, callback) {
// ...
}
To keep your custom claims from colliding with any reserved or external claims, you must give them a globally unique name using a namespaced format. By default, Auth0 always enforces namespacing and silently excludes from the tokens any custom claims with non-namespaced identifiers.
Namespaces are arbitrary identifiers, so technically, you can call your namespace anything you want. For convenience, the namespace value is the API audience value set in the WHATABYTE Dashboard Demo Settings.
function(user, context, callback) {
const namespace = 'https://menu-api.example.com';
//...
}
You then check if the context
object has an authorization
property and, in turn, if that property has a roles
property:
function(user, context, callback) {
const namespace = 'https://menu-api.example.com';
if (context.authorization && context.authorization.roles) {
// ...
}
// ...
}
context.authorization
is an object containing information related to the authorization transaction, such as roles.
context.authorization.roles
is an array of strings containing the names of the roles assigned to a user.
Next, you assign the roles
array to the assignedRoles
constant and check if there is an ID token or access token present in the context
object:
function(user, context, callback) {
const namespace = 'https://menu-api.example.com';
if (context.authorization && context.authorization.roles) {
const assignedRoles = context.authorization.roles;
if (context.idToken) {
// ...
}
if (context.accessToken) {
// ...
}
}
// ...
}
If any of these tokens are present, you add to the token object a <namespace>/roles
property with the roles array, assignedRoles
, as its value, effectively creating a custom claim on the token that represents the user roles:
function(user, context, callback) {
const namespace = 'https://menu-api.example.com';
if (context.authorization && context.authorization.roles) {
const assignedRoles = context.authorization.roles;
if (context.idToken) {
const idTokenClaims = context.idToken;
idTokenClaims[`${namespace}/roles`] = assignedRoles;
context.idToken = idTokenClaims;
}
if (context.accessToken) {
const accessTokenClaims = context.accessToken;
accessTokenClaims[`${namespace}/roles`] = assignedRoles;
context.accessToken = accessTokenClaims;
}
}
// ...
}
Finally, you invoke the callback
function to send the potentially modified tokens back to Auth0, which in turn sends them to the client:
function(user, context, callback) {
// ...
callback(null, user, context);
}
That's all you need to create an Auth0 rule that adds user roles to tokens. What's left to do is for you to create a user that has the menu-admin
role.
Before you do that, verify how the user interface restricts access to certain user interface elements and views when a user doesn't have the menu-admin
role.
Head back to the demo client.
Next, click on the "Settings" tab from the left-hand navigation bar and click the "Modify" button to change the demo settings.
The "Auth0 Demo Settings" view loads up. Enable Role-Based Access Control (RBAC), which reveals the User Role field. Populate that field with the following value: menu-admin
.
Once you set that value, leave every other field as it is. Then, click on the Save button.
Once you are back to the application, sign in. Notice how the Add Item button is no longer visible in the "Menu Items" page. If you click on a menu item, you won't see the Edit or Delete buttons either.
You need to grant yourself or any other user you create admin access!
Create an admin user
Open the Users page from the Auth0 Dashboard and click on Create User. Fill the form that pops up with the following:
Email:
admin@example.com
Password and Repeat Password: Any password of your choice
Connection:
Username-Password-Authentication
Click on the Create button. The page of the admin@example.com
user loads up. On this page, click on the "Roles" tab and then click on the Assign Roles button.
From the dropdown, select the menu-admin
role that you created earlier and click on the Assign button. Verify that the user has the permissions by clicking on the "Permissions" tab. If so, your admin user is all set up and ready to use.
As an alternative, you may assign the menu-admin
role to the existing user you have been using to access the demo application.
Sign In as Admin
Head back to the demo client and sign out.
Click on the Sign In button again and, this time, login in as the admin@example.com
user or as any user that you have granted the menu-admin
role.
This time around, the UI unlocks admin features. Open the "Menu" page and notice the "Add Item" button is back at the top-right corner. Click on a menu item and notice how you can now edit or delete the item.
However, at this moment, non-admin users could circumvent the client-side route protections to unlock the admin features of the UI. Additionally, they could extract the access token sent by Auth0 using the browser's developer tools and make requests directly to the server write endpoints using the terminal, for example.
Your server needs to implement role-based access control to mitigate these attack vectors.
Implement Role-Based Access Control
To implement role-based access control (RBAC) in Express, you create an RBAC middleware function that inspects the access token provided in the client request and verifies that it has the permissions required by the endpoint it needs to access. You also must call this function before your application reaches the route handler function of the protected endpoint controller.
Consequently, if the proper permissions are present in the access token, your RBAC middleware function calls the next middleware function in the chain, effectively granting the client request with access to the protected endpoint. Otherwise, your application terminates the request-response cycle and sends a response with a 403 Forbidden
status code to the client.
To help you inspect the access token for permissions easily, you will use the express-jwt-authz
package:
npm install express-jwt-authz
Using this package, you will inspect the permissions
claim of the access token to determine if the client making a request to a protected endpoint has all the permissions required.
To start, create a file to define a middleware function that checks for permissions:
touch src/middleware/permissions.middleware.ts
Then, populate src/middleware/permissions.middleware.ts
with the following code:
const jwtAuthz = require("express-jwt-authz");
export const checkPermissions = (permissions: string | string[]) => {
return jwtAuthz([permissions], {
customScopeKey: "permissions",
checkAllScopes: true,
failWithError: true
});
};
The jwtAuthz
function takes as a first argument an array of strings representing the permissions required by an endpoint. Its second argument is an optional configuration object. You can configure how jwtAuthz
should behave by specifying different options as follows:
customScopeKey
: By default,jwtAuthz
checks permissions against thescope
claim of the access token. You can use this option to change the claimjwtAuthz
should use. In this case, you specify that the access token stores permission-related data in a claim calledpermissions
.checkAllScopes
: When set totrue
, all the expected permissions by the endpoint must be present in thecustomScopeKey
claim of the access token. If any permission is missing, this middleware function throws an error, which effectively denies the client application making the request from accessing the protected endpoint.failWithError
: When set totrue
,jwtAuthz
will forward any errors to thenext()
middleware function instead of ending the response directly. In this case,jwtAuthz
will forward the error to yourerrorHandler
middleware function, where you can better customize the error response sent to the client.
jwtAuthz
is a fully-defined and self-contained middleware function, which means it is a function that has access to the Request
object, the Response
object, and the next
middleware function in the application’s request-response cycle. As such, you can technically avoid creating the checkPermissions
helper function and invoke the jwtAuthz
function directly on each endpoint as follows:
itemsRouter.post(
"/",
[
checkJwt,
jwtAuthz([ItemPermissions.CreateItems], {
customScopeKey: "permissions",
checkAllScopes: true,
failWithError: true
})
],
async (req: Request, res: Response) => {
// function body...
}
);
itemsRouter.put(
"/",
[
checkJwt,
jwtAuthz([ItemPermissions.UpdateItems], {
customScopeKey: "permissions",
checkAllScopes: true,
failWithError: true
})
],
async (req: Request, res: Response) => {
// function body...
}
);
However, this requires you to repeatedly configure jwtAuthz
at each endpoint. Instead, you use a JavaScript closure to create a re-usable functional wrapper for jwtAuthz
. The checkPermissions
helper function takes as arguments the permissions
required and creates a closure around that value within its body. It then returns an instance of jwtAuthz
, which can access the value of permissions
when Express executes it. As such, you only need to configure jwtAuthz
in a single place, making your code much more maintainable and less error-prone. You'll apply this approach to the endpoints.
With the RBAC authorization middleware function created, you are now ready to wire it into any controller that needs role-based access control (RBAC).
Define permissions locally
To make it easy to manage and use permissions in your code, you can define them using a TypeScript enum
. Under the src/items
directory, create the following file:
touch src/items/item-permission.ts
Populate src/items/item-permission.ts
as follows:
export enum ItemPermission {
CreateItems = "create:items",
UpdateItems = "update:items",
DeleteItems = "delete:items",
}
A TypeScript enum
lets you define a set of named constants, which documents what these constants do while also preventing you from introducing bugs in your application by mistyping them. Each constant represents one of the permissions that you created in the Auth0 dashboard.
Apply permissions middleware
To protect the write endpoints with RBAC, you need to inject the checkPermissions
middleware function into a controller definition and pass it the permissions that the endpoint requires as arguments.
Open src/items/items.router.ts
, locate the Required External Modules and Interfaces
section, and add the following imports:
/**
* Required External Modules and Interfaces
*/
import express, { Request, Response } from "express";
import * as ItemService from "./items.service";
import { BaseItem, Item } from "./item.interface";
import { checkJwt } from "../middleware/authz.middleware";
import { checkPermissions } from "../middleware/permissions.middleware";
import { ItemPermission } from "./item-permission";
Next, locate the Controller Definitions
section and update the following controller definitions:
/**
* Controller Definitions
*/
// GET items/ ...
// GET items/:id ...
// Mount authorization middleware
itemsRouter.use(checkJwt);
// POST items
itemsRouter.post(
"/",
checkPermissions(ItemPermission.CreateItems),
async (req: Request, res: Response) => {
try {
const item: BaseItem = req.body;
const newItem = await ItemService.create(item);
res.status(201).json(newItem);
} catch (e) {
res.status(500).send(e.message);
}
}
);
// PUT items/:id
itemsRouter.put(
"/:id",
checkPermissions(ItemPermission.UpdateItems),
async (req: Request, res: Response) => {
const id: number = parseInt(req.params.id, 10);
try {
const itemUpdate: Item = req.body;
const existingItem: Item = await ItemService.find(id);
if (existingItem) {
const updatedItem = await ItemService.update(id, itemUpdate);
return res.status(200).json(updatedItem);
}
const newItem = await ItemService.create(itemUpdate);
res.status(201).json(newItem);
} catch (e) {
res.status(500).send(e.message);
}
}
);
// DELETE items/:id
itemsRouter.delete(
"/:id",
checkPermissions(ItemPermission.DeleteItems),
async (req: Request, res: Response) => {
try {
const id: number = parseInt(req.params.id, 10);
await ItemService.remove(id);
res.sendStatus(204);
} catch (e) {
res.status(500).send(e.message);
}
}
);
Now that the authorization guards are in place, any attempt to create a new menu item directly using a non-admin access token results in failure:
The
checkPermissions()
function injected in thePOST api/menu/items/
endpoint detects the presence of the required permissions.If the required permissions are missing, it then passes a
403
exception down the middleware chain.As such, you successfully prevent the application from invoking the route handler of this endpoint.
Remember that by mounting the checkJwt()
middleware function as a router-level middleware, you don't have to include it at the endpoint-level. With just one line of code, itemsRouter.use(checkJwt)
, you are protecting all the endpoints that follow it against invalid access tokens. By subsequently applying the checkPermissions()
middleware at the route-controller-level, you protect that route from authenticated requests that lack the required access level.
You are effectively implementing Role-Based Access Control (RBAC) using a two-layer approach powered by Express middleware functions.
Sign out and sign back in as the admin user in the demo client. Try to add a new item. The "Add Item" page has a form pre-loaded with some data to make this process easier for you. If you already created the salad item, try to create a coffee item with this data:
name: Coffee
price: 299
description: Woke
image: https://images.ctfassets.net/23aumh6u8s0i/6HS0xLG6bx52KJrqyqfznk/50f9350a7791fa86003024af4762f4ca/whatabyte_coffee-sm.png
Click on that newly created item and notice that you can either edit or delete it. Try both operations.
Security Exercise: Remove the Admin Role
Log out from the demo application.
Click on the Settings tab on the left-hand navigation bar of the demo client. Then, click on the Modify button.
The "Auth0 Demo Settings" page loads up. Delete the value of User Role, leave it blank, then click the Save button.
Now, either:
(a) sign in as a non-admin user, or
(b) remove the menu-admin
role from your current user in the Auth0 Dashboard and sign in as that user.
You'll have access to the admin UI elements. Click on the "Tea" item and try to delete it. You'll get an error message, Insufficient scope
:
This error message is telling you that you don't have enough permissions to perform that action. If you inspect the "Network" or "Console" tab of your browser's developer tools, you'll notice that your Express API server replied with a 403 (Forbidden)
error.
You'll get the same type of error if you try to add or edit an item. You have confirmed that your Express API server is effectively guarding your write endpoints from unauthenticated users and from authenticated users who lack the permissions to access them.
Click on the Settings tab on the left-hand navigation and click on the Modify button. Restore the value of User Role back to menu-admin
and save your changes. If you removed the menu-admin
role from a user, head back to the Auth0 Dashboard and give back the role to the user.
What's Next
You have implemented authorization in Express to control the resources that your users can access. You have learned how to implement different access levels:
- Access based on authentication status.
- If you have logged in, you are authorized to access the resources.
- Access based on permissions.
If you have logged in and have the required permissions, you are authorized to access the resources.
This tutorial covered the most common authorization use cases for an Express API server. However, Auth0 is an extensible and flexible platform that can help you achieve even more. If you have a more complex use case, check out the [Auth0 Architecture Scenarios](https://auth0.com/docs/architecture-scenarios) to learn more about the typical architecture scenarios we have identified when working with customers on implementing Auth0.
Let me know in the comments below what you thought of this tutorial. Thank you for reading this far, and happy engineering!