Create a Refresh Token Feature in your Strapi Application

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.


This is a companion discussion topic for the original entry at https://strapi.io/blog/how-to-create-a-refresh-token-feature-in-your-strapi-application

There is an issue where we do not pass the cookies (i.e. delete the cookies from postman) and only pass the refreshToken in body it will try to assign the token to cookie variable, but that variable is constant so the code throws the below error:

TypeError: Assignment to constant variable.

Error thrown here:

refreshCookie = refreshToken

Here is a PR for the bug fix: Bug fix when assigning refreshToken from body to a constant by umair-me · Pull Request #1 · Marienoir/Strapi-Refresh-Token-v4 · GitHub

There are some “missing links”…
When I request api
POST http://localhost:1337/api/token/refresh
body : { refreshToken:"~~" }

it returns 403

I solved!
to make api/token/refresh to public
add “auth:false” in config.

plugin.routes['content-api'].routes.push({
    method: 'POST',
    path: '/token/refresh',
    handler: 'auth.refreshToken',
    config: {
      policies: [],
      prefix: '',
      auth:false
    }
  });
1 Like

Thanks @guhyeon this solved my issue.

The auth:false key-value pair in the config object of the /api/token/refresh route allows requests to bypass the authentication middleware. Without it, the server will check for a valid JWT token, which would be expired, resulting in a 403 forbidden error. Without this key-value pair, it is impossible to make a request to this route as the default behavior of the server is to use the authentication middleware for all routes, resulting in an unauthorized access.

1 Like

There is something I don’t understand… what do the env variables the author mentions here do? i.e.

REFRESH_TOKEN_EXPIRES=2d
JWT_SECRET_EXPIRES=360s

I get that the first one controls how long the refresh token lasts, but what does the second one do? If the second one controls how long the jwt token is valid for, then what is the point in the plugins.js file the author has us create?

This is because we don’t want JWTs that have a long expiry date.

So the principle is:

Have a valid JWT during the session which you are in. You should never store a JWT in localstorage for example. The JWT should have a short validity date because of the risk that someone could hijack your JWT and use it.

The refresh token is used for getting a new token every 360s :slight_smile:

Hi I’m following the guide but there is 2 things not clear for me.
This is the entire code of the strapi-server.js file provided in the guide.

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 { 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 });
};

// 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 }
    );
}

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.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),
                });
            }

            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 the 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);
        }
    }
    plugin.controllers.auth['refreshToken'] = async (ctx) => {
        const store = await strapi.store({ type: 'plugin', name: 'users-permissions' });

        const { refreshToken } = ctx.request.body;
        let 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: '10d' }),
                refreshToken: refreshToken,
            });
        }
        catch (err) {
            return ctx.badRequest(err.toString());
        }
    }
    plugin.routes['content-api'].routes.push({
        method: 'POST',
        path: '/token/refresh',
        handler: 'auth.refreshToken',
        config: {
            policies: [],
            prefix: '',
        }
    });
    return plugin

}

The first thing I cannot understand is inside the condition if (provider === 'local').
At the end of the condition we have the ctx.cookies.set() function and then the ctx.send() function but the return at the end of the function is

return ctx.send({
                jwt: getService('jwt').issue({ id: user.id }),
                user: await sanitizeUser(user, ctx),
            });

Which function is fired? and why in the first ctx.send() issueJWT() is used and in the second getService('jwt').issue()

1 Like

I had this working locally however after deploying and ensuring permissions etc configured under roles are correct I am getting a 401 error on the refresh endpoint. any suggestions?