How to Create a Refresh Token Feature in your Strapi Application

How to Create a Refresh Token Feature in your Strapi Application

This article explains how to create a refresh token feature in your Strapi application.

Author: Okosun Mary

Every user-centric backend service, like Strapi, depends on authentication and user management because different users may have varying roles and permissions. Strapi is an open-source and headless content management system (CMS) that gives developers the freedom to use their favourite tools and frameworks during development.

Why Authenticate Your Strapi Application?

By allowing only authenticated users (or processes) to access their protected resources, authentication enables organizations to maintain the security of their networks. Authentication is the process of validating that a person or entity is, in fact, who or what it claims to be.

Prerequisites

To follow in this project tutorial:

  • You need to download and install Node.js (versions 16.x are recommended by Strapi).
  • You need to have npm (version 6 only) or yarn to run the CLI installation scripts.
  • You need to have a basic knowledge of JavaScript and Vue.js.

Goals

This tutorial gives an approach on how to add a user as a content-type, how to test an authenticated user and why you need to authenticate a user. You will learn how to implement a refresh token for an authenticated user. You will also learn how to create a mini-app using Vue.js, a JavaScript framework, and Strapi that showcases how an authenticated user can have access to the dashboard by creating a refresh token for this user. At the end of this tutorial, you should know how to create a refresh token feature in your Strapi application.

Backend Implementation

You will be using Strapi for the backend implementation. This would be done in multiple steps:

1. Scaffolding a Strapi Project You will be running the Strapi project locally and using the Strapi CLI (Command Line Interface) installation scripts as it is the fastest way to get Strapi running locally. A new Strapi instance, strapi-refresh-token-backend, will be created in a specified directory on your machine.

        npx create-strapi-app strapi-refresh-token-backend --quickstart
        #OR
        yarn create-strapi-app strapi-refresh-token-backend --quickstart

In the directory you specified, the code snippets above will create a new Strapi project. It should automatically open http://localhost:1337/admin/auth/register-admin on your browser. If not, you can start the admin panel on your browser by executing the following command in your terminal.

        npm run develop
        # OR
        yarn run develop

To register as the system's new admin, a new window would open.

Strapi Admin Panel

You can complete the form and press the submit button. You will then be redirected to the admin panel.

Strapi Admin Dashboard - Overview

2. Create a New User On the Strapi admin dashboard, navigate to the content manager and in the user collection type, you will create a new entry for a user.

Strapi Admin Dashboard - Content Manager

Fill in the following details on the form:

Click on the Save button to save your new user.

Strapi Admin Dashboard - Create new entry for user

3. Testing an Authenticated User in an API Client. Here, you will test your new user on Postman. You can use an API client of your choice; however, for this tutorial, Postman will be used to test the API endpoints. Navigate to your Postman client and send a POST request to http://localhost:1337/api/auth/local. The response body should be similar to the following:

        {
            "identifier": "marynoir",
            "password": "marynoir"
        }
  • Identifier: This can either be the users’ email or username.
  • Password: This is the password of the user

The response body should be similar to the following:

        {
            "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNjYxOTQ0OTQ4LCJleHAiOjE2NjQ1MzY5NDh9.yeYcS8kA_TI9JqVn4Xnqu0lKiT4BUgnM7l8HFKJ56hc",
            "user": {
                "id": 1,
                "username": "marynoir",
                "email": "mary@gmail.com",
                "provider": "local",
                "confirmed": true,
                "blocked": false,
                "createdAt": "2022-08-31T11:22:24.613Z",
                "updatedAt": "2022-08-31T11:22:24.613Z"
            }
        }

Postman API Client

4. Introduction to the Refresh Token Feature From the response gotten above, you can see the jwt in the response body. A jwt token may be used for making permission-restricted API requests. In this tutorial, the jwt token will be used to give the user access to the application and when the token is expired, the users’ access gets restricted. The user will be mandated to request for another jwt token using the refresh token feature in order to have access to the application again. You will be creating a refresh token by configuring some folders and files in the Strapi directory.

  • Step 1: In the .env file, add the following environment variables:

      // .env
    
      REFRESH_SECRET=strapisecret
      REFRESH_TOKEN_EXPIRES=2d
      JWT_SECRET_EXPIRES=360s
      NODE_ENV=development
    
  • Step 2: In the src/extensions folder, create a folder named users-permissions. Within this folder, create another folder called controllers/validation. Inside this folder, create a file auth.js. Add the following code snippets to the auth.js file:
      // ../users-permissions/controllers/validation/auth.js
    
          'use strict';
          const { yup, validateYupSchema } = require('@strapi/utils');
          const callbackBodySchema = yup.object().shape({
            identifier: yup.string().required(),
            password: yup.string().required(),
          });
          module.exports = {
            validateCallbackBody: validateYupSchema(callbackBodySchema)
          };
    

The code above creates a schema, callbackBodySchema, that requires an identifier and password for the login authentication. This authentication is similar to the Strapi login system.

  • Step 3: In the users-permissions folder, create a new folder called utils. Within this folder, create a file called index.js and add the following code snippets to the file:
        // ../users-permissions/utils/index.js

        'use strict';
        const getService = name => {
          return strapi.plugin('users-permissions').service(name);
          return
        };
        module.exports = {
          getService,
        };
  • Step 4: In the users-permissions folder, create a new file called strapi-server.js. Add the following code snippets to the strapi-server.js file:

          // ../users-permissions/strapi-server.js
    
          const utils = require('@strapi/utils');
          const { getService } = require('../users-permissions/utils');
          const jwt = require('jsonwebtoken');
          const _ = require('lodash');
          const {
              validateCallbackBody
          } = require('../users-permissions/controllers/validation/auth');
    
          const { setMaxListeners } = require('process');
          const { sanitize } = utils;
          const { ApplicationError, ValidationError } = utils.errors;
          const sanitizeUser = (user, ctx) => {
              const { auth } = ctx.state;
              const userSchema = strapi.getModel('plugin::users-permissions.user');
              return sanitize.contentAPI.output(user, userSchema, { auth });
          };
    
          module.exports = (plugin) => {
           return plugin
          }
    
  • Step 5: In the next step, you will be using the existing login procedure from Strapi, which can be found in the node-modules folder under @strapi/plugin-users-permissions/server/controllers/auth.js.

Add the following snippets within the module.export function:

        // ../users-permissions/strapi-server.js

        module.exports = (plugin) => {
            plugin.controllers.auth.callback = async (ctx) => {
                const provider = ctx.params.provider || 'local';
                const params = ctx.request.body;
                const store = strapi.store({ type: 'plugin', name: 'users-permissions' });
                const grantSettings = await store.get({ key: 'grant' });
                const grantProvider = provider === 'local' ? 'email' : provider;
                if (!_.get(grantSettings, [grantProvider, 'enabled'])) {
                    throw new ApplicationError('This provider is disabled');
                }
                if (provider === 'local') {
                    await validateCallbackBody(params);
                    const { identifier } = params;
                    // Check if the user exists.
                    const user = await strapi.query('plugin::users-permissions.user').findOne({
                        where: {
                            provider,
                            $or: [{ email: identifier.toLowerCase() }, { username: identifier }],
                        },
                    });
                    if (!user) {
                        throw new ValidationError('Invalid identifier or password');
                    }
                    if (!user.password) {
                        throw new ValidationError('Invalid identifier or password');
                    }
                    const validPassword = await getService('user').validatePassword(
                        params.password,
                        user.password
                    );
                    if (!validPassword) {
                        throw new ValidationError('Invalid identifier or password');
                    } else {
                         ctx.send({
                            jwt: getService('jwt').issue({
                                id: user.id,
                            }),
                            user: await sanitizeUser(user, ctx),
                        });
                    }
                    const advancedSettings = await store.get({ key: 'advanced' });
                    const requiresConfirmation = _.get(advancedSettings, 'email_confirmation');
                    if (requiresConfirmation && user.confirmed !== true) {
                        throw new ApplicationError('Your account email is not confirmed');
                    }
                    if (user.blocked === true) {
                        throw new ApplicationError('Your account has been blocked by an administrator');
                    }
                    return ctx.send({
                        jwt: getService('jwt').issue({ id: user.id }),
                        user: await sanitizeUser(user, ctx),
                    });
                }
                // Connect the user with a third-party provider.
                try {
                    const user = await getService('providers').connect(provider, ctx.query);
                    return ctx.send({
                        jwt: getService('jwt').issue({ id: user.id }),
                        user: await sanitizeUser(user, ctx),
                    });
                } catch (error) {
                    throw new ApplicationError(error.message);
                }
            }
            return plugin
        }

The code above checks if a Strapi provider such as Google or Auth0 is used for login authentication. In this tutorial, you are not using an external provider, so the provider variable would be local.

If the provider is local, it would confirm that the user exists using the identifier field. If the user exists, it would check if the password in the request body is the same as the password used in registration. If the password matches, the user get logged in.

  • Step 6: You would need to have a refresh cookie that will be used alongside the jwt token in order to generate a new refresh token. You need to add the following code snippets just below the sanitizeUser function in the strapi-server.js file:

          // ../users-permissions/strapi-server.js
          const sanitizeUser = (user, ctx) => {
             ...
          };
    
          // issue a JWT
          const issueJWT = (payload, jwtOptions = {}) => {
              _.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
              return jwt.sign(
                  _.clone(payload.toJSON ? payload.toJSON() : payload),
                  strapi.config.get('plugin.users-permissions.jwtSecret'),
                  jwtOptions
              );
          }
    
          // verify the refreshToken by using the REFRESH_SECRET from the .env
          const verifyRefreshToken = (token) => {
              return new Promise(function (resolve, reject) {
                  jwt.verify(token, process.env.REFRESH_SECRET, {}, function (
                      err,
                      tokenPayload = {}
                  ) {
                      if (err) {
                          return reject(new Error('Invalid token.'));
                      }
                      resolve(tokenPayload);
                  });
              });
          }
    
          // issue a Refresh token
          const issueRefreshToken = (payload, jwtOptions = {}) => {
              _.defaults(jwtOptions, strapi.config.get('plugin.users-permissions.jwt'));
              return jwt.sign(
                  _.clone(payload.toJSON ? payload.toJSON() : payload),
                  process.env.REFRESH_SECRET,
                  { expiresIn: process.env.REFRESH_TOKEN_EXPIRES }
              );
          }
    

In Line 7-14, the function issueJWT creates a new jwt token which will be used when requesting for a refresh token.

In Line 17-29, the verifyRefreshToken function is used to verify that the refresh token passed in the request body while requesting for a new jwt is actually valid. It uses the jwt.verify() function to verify that the token is valid with the REFRESH_SECRETin the .env file. If this is valid, it returns a new token for the user, else it returns an errorInvalid token`.

In Line 32-39, the issueRefreshToken function is used to create a new refresh token that will be stored in the cookie.

Now that you can create a refresh token, you need to be able to store this refresh token in the cookies. Replace the content of the isValidPassword check with the following code snippets. The snippets sets the refresh token with the name refreshToken as the cookie name if the password is valid.

        // ../users-permissions/strapi-server.js

        if (!validPassword) {
          throw new ValidationError('Invalid identifier or password');
        } else {
            ctx.cookies.set("refreshToken", issueRefreshToken({ id: user.id }), {
                httpOnly: true,
                secure: false,
                signed: true,
                overwrite: true,
        });
            ctx.send({
              status: 'Authenticated',
              jwt: issueJWT({ id: user.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }),
              user: await sanitizeUser(user, ctx),
            });
        }
  • Step 7: Let's test what you have done so far. At this stage, you have been able to refactor a login system for our application. If a registered user logs in, the user should have a jwt token and also a refresh token saved in the cookies. You can send a POST request to the login api route http://localhost:1337/api/auth/local and see the refreshToken saved in the cookies. Postman API Client - Cookies

  • Step 8: The next step is to create a function that would take in the refresh token and issue a new jwt for the user. Add the code snippets below the plugin.controllers.auth.callback function:

          // ../users-permissions/strapi-server.js
    
          plugin.controllers.auth.callback = async (ctx) => {
              ......
          }   
          plugin.controllers.auth['refreshToken'] = async (ctx) => {
                  const store = await strapi.store({ type: 'plugin', name: 'users-permissions' });
                  const { refreshToken } = ctx.request.body;
                  const refreshCookie = ctx.cookies.get("refreshToken")
    
                  if (!refreshCookie && !refreshToken) {
                      return ctx.badRequest("No Authorization");
                  }
                  if (!refreshCookie) {
                      if (refreshToken) {
                          refreshCookie = refreshToken
                      }
                      else {
                          return ctx.badRequest("No Authorization");
                      }
                  }
                  try {
                      const obj = await verifyRefreshToken(refreshCookie);
                      const user = await strapi.query('plugin::users-permissions.user').findOne({ where: { id: obj.id } });
                      if (!user) {
                          throw new ValidationError('Invalid identifier or password');
                      }
                      if (
                          _.get(await store.get({ key: 'advanced' }), 'email_confirmation') &&
                          user.confirmed !== true
                      ) {
                          throw new ApplicationError('Your account email is not confirmed');
                      }
                      if (user.blocked === true) {
                          throw new ApplicationError('Your account has been blocked by an administrator');
                      }
                      const refreshToken = issueRefreshToken({ id: user.id })
                      ctx.cookies.set("refreshToken", refreshToken, {
                          httpOnly: true,
                          secure: false,
                          signed: true,
                          overwrite: true,
                      });
                      ctx.send({
                          jwt: issueJWT({ id: obj.id }, { expiresIn: process.env.JWT_SECRET_EXPIRES }),
                          refreshToken: refreshToken,
                      });
                  }
                  catch (err) {
                      return ctx.badRequest(err.toString());
                  }
              }
    

    The snippets above get the refreshToken from the cookies and saves it as refreshCookie. If the refreshCookie is not found, it returns an error of No Authorization. If the refreshCookie is found, it gets verified using the verifyRefreshToken() created earlier. Checks such as if the user exist, if the users’ email is not confirmed and if the users’ account has been blocked are made. It creates a new jwt using the issueJWT() and assigns it to the user.

  • Step 9: The final step is to create an api route to refresh our jwt and request for a new token. Add the following code before the return plugin statement:

        // ../users-permissions/strapi-server.js

        plugin.controllers.auth.callback = async (ctx) => {
            ......
        }
        plugin.controllers.auth['refreshToken']= async (ctx) => {
            ......
        } 
        plugin.routes['content-api'].routes.push({
                method: 'POST',
                path: '/token/refresh',
                handler: 'auth.refreshToken',
                config: {
                    policies: [],
                    prefix: '',
                }
        });

Let us test what you have done so far. You can send a POST request to the refresh token api route localhost:1337/api/token/refresh and see the new jwt and refreshToken in the response body.

screenshot

By default, Strapi gives a validation token (jwt) valid for 30 days. For the purpose of this project, you would manually configure the expiration date so that our application can be tested faster. Create a plugins.js file in the config folder and add the following code snippets:

        // config/plugins.js

        module.exports = ({ env }) => ({
            'users-permissions': {
              enabled: true,
              config: {
                jwt: {
                  expiresIn: '15m',
                },
              },
            },
          });

Frontend Implementation

You have built the backend services and the refresh token feature configured; the next step is to create the frontend application to consume the Strapi APIs with Vue.js. The frontend application will be a mini-app that has two (2) screens: the login and dashboard interface. A registered user can log in and be directed to the dashboard screen. When the token of such user expires, the user will be prompted to request for another token. If the user does not request for a new token, they will be logged off the application.

According to the documentation, Vue.js is a JavaScript framework for building user interfaces. It builds on top of standard HTML, CSS, and JavaScript, and provides a declarative and component-based programming model that helps you efficiently develop user interfaces, be it simple or complex. To create a new Vue.js project, follow these steps to get started:

  1. Navigate to a directory and install the Vue.js package using the command:
     npm install -g @vue/cli
     # OR
     yarn global add @vue/cli
    
  2. Create a new project using the command:
     vue create strapi-refresh-token-frontend
    

You will be prompted to pick a preset. Select "manually select features" to pick the features we need. You would select Vuex, Router, and Lint/Formatter. Vuex is a state management library for Vue applications; Router allows to change the URL without reloading the page and Lint/Formatter properly formats the codes.

After successfully creating your project, navigate to the folder directory and run the application:

    cd strapi-refresh-token-frontend
    npm run serve
    #OR
    yarn run serve

The URL http://localhost:8080/ should open your Vue.js application in your browser.

Vue layout

Dependency Installation

You need to install some dependencies such as axios. Axios is the package dependency that will be used to make the call to the Strapi backend APIs:

    npm install axios

Firstly, delete the HelloWorld.vue file in the components folder, the HomeView.vue, and AboutView.vue files in the views folder as these files are redundant in this project.

Create two new files Login.vue and Dashboard.vue in the components folder and copy the following contents into the login.vue

        //Login.vue

        <template>
          <div class="login">
            <input
              type="text"
              v-model="identifier"
              placeholder="Enter username/email"
            />
            <input 
              type="text" 
              v-model="password" 
              placeholder="Enter password" 
            />
            <div>
              <button @click="login">LOGIN</button>
            </div>
          </div>
        </template>
        <script>
        import axios from "axios";
        export default {
          name: "login",
          data() {
            return {
              identifier: "",
              password: "",
            };
          },
          methods: {
            async login() {
              try {
                const data = {
                  identifier: this.identifier,
                  password: this.password,
                };
                const options = {
                  credentials: "include",
                  withCredentials: true,
                };
                const res = await axios.post(
                  "http://localhost:1337/api/auth/local",
                  data,
                  options
                );
                localStorage.setItem("token", res.data.jwt);
                localStorage.setItem("user", JSON.stringify(res.data.user));
                if (res.status === 200) {
                  this.$router.push("/dashboard");
                }
              } catch (err) {
                console.log(err);
              }
            },
          },
        };
        </script>
        <style scoped>
        .login {
          display: flex;
          flex-direction: column;
          padding: 35px;
          background: #e8e8e8;
        }
        input {
          padding: 15px;
          margin: 5px 0;
          border-radius: 2px;
          border: 1px solid white;
        }
        button {
          background: #36865d;
          color: white;
          border: none;
          padding: 15px 25px;
          width: 100%;
          margin-top: 5px;
          font-weight: bolder;
        }
        button:hover {
          background: #4cab7a;
        }
        </style>

In the Dashboard.vue file, add the following contents:

        <template>
          <div>
            <h1>User Dashboard</h1>
            <ul>
              <li><b>Username:</b> {{ getUser.username }}</li>
              <li><b>Email:</b> {{ getUser.email }}</li>
              <li><b>Is User Confirmed:</b> {{ getUser.confirmed }}</li>
              <li><b>Is User Blocked: </b>{{ getUser.blocked }}</li>
              <li><b>Provider:</b> {{ getUser.provider }}</li>
            </ul>
          </div>
        </template>
        <script>
        export default {
          name: "dashboard",
          computed: {
            getUser() {
              let jwtPayload = JSON.parse(localStorage.getItem("user"));
              return jwtPayload;
            },
          },
        };
        </script>
        <style scoped>
        ul {
          list-style-type: none !important;
          padding: 0;
        }
        </style>

In the views folder, create a LoginView.vue file and copy the following content:

        <template>
          <div class="container">
            <Login />
          </div>
        </template>
        <script>
        import Login from "@/components/Login.vue";
        export default {
          name: "LoginView",
          components: {
            Login,
          },
        };
        </script>
        <style scoped>
        .container {
          margin: 50px auto;
          width: 400px;
        }
        </style>

A modal will be built that will pop up when the token is expired and requests the user to choose if the application should refresh the token or not. In the components folder, create a Modal.vue file and copy the following code snippets:

        <template>
          <div class="modal-overlay" @click="$emit('close-modal')">
            <div class="modal" @click.stop>
              <h6>Token Expired!</h6>
              <p>You are unable to view your dashboard.</p>
              <p>Do you want to refresh your token?</p>
              <button id="no-button" @click="handleNoButton">No</button>
              <button id="yes-button" @click="getRefreshToken">Yes</button>
            </div>
          </div>
        </template>
        <script>
        import axios from "axios";
        export default {
          methods: {
            handleNoButton() {
              this.$router.push("/");
            },
            async getRefreshToken() {
              try {
                const data = {
                  refreshToken: localStorage.getItem("token"),
                };
                const options = {
                  "Access-Control-Allow-Credentials": true,
                  withCredentials: true,
                };
                const res = await axios.post(
                  "http://localhost:1337/api/token/refresh",
                  data,
                  options
                );
                localStorage.setItem("token", res.data.jwt);
                this.$emit("close-modal");
              } catch (err) {
                console.log(err);
              }
            },
          },
        };
        </script>
        <style scoped>
        .modal-overlay {
          position: fixed;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
          display: flex;
          justify-content: center;
          background-color: #000000da;
        }
        .modal {
          text-align: center;
          background-color: white;
          height: 200px;
          width: 400px;
          margin-top: 10%;
          padding: 60px 0;
          border-radius: 20px;
        }
        .close {
          margin: 10% 0 0 16px;
          cursor: pointer;
        }
        .close-img {
          width: 25px;
        }
        .check {
          width: 150px;
        }
        h6 {
          font-weight: 500;
          font-size: 28px;
          margin: 20px 0;
        }
        p {
          font-size: 16px;
        }
        button {
          width: 100px;
          height: 40px;
          color: white;
          font-size: 14px;
          border-radius: 12px;
          margin-top: 10px;
          margin-right: 10px;
          border: 1px solid #fff;
        }
        #yes-button {
          background-color: #4cab7a;
        }
        #no-button {
          background-color: #ba0000da;
        }
        </style>

Refactor the index.js file to suit the changes done so far. It should be similar to the following:

        import { createRouter, createWebHistory } from "vue-router";
        import LoginView from "../views/LoginView.vue";
        const routes = [
          {
            path: "/",
            name: "login",
            component: LoginView,
          },
          {
            path: "/dashboard",
            name: "dashboard",
            // route level code-splitting
            // this generates a separate chunk (about.[hash].js) for this route
            // which is lazy-loaded when the route is visited.
            component: () =>
              import(/* webpackChunkName: "about" */ "../views/Dashboard.vue"),
          },
        ];
        const router = createRouter({
          history: createWebHistory(process.env.BASE_URL),
          routes,
        });
        export default router;

Testing the Application

Now you can test the frontend implementation of the application. Go to http://localhost:8080/ and you should see the login page

login page

Enter the username and password that was created for the user on the Strapi backend entry. If the details are correct, you would be routed to the dashboard page.

user dashboard

When the token expires, the modal pops up.

expired token modal

If you click on the No button, the app routes you back to the login page so you can log in again to regenerate a new token. If you click on the Yes button, the app makes an API call to the refresh token API and automatically reissues a new jwt and refresh token. This allows you to continue browsing the application without having to log in each time. To ascertain that this really works, you can check the refreshToken stored in the cookie and the token stored in the localstorage. With each click on the Yes button, a new refreshToken is generated.

sample

Conclusion

In this tutorial, you learned how to add and authenticate a user using jwt. A demonstration on how to implement a refresh token for an authenticated user using the jwt from a user login activity was done.

You can download the source code for the frontend and backend implementation from Github.