DEV Community

Cover image for Authentication API with JWT access token and refresh token - NodeJS
Smitter
Smitter

Posted on • Updated on

Authentication API with JWT access token and refresh token - NodeJS

part 5

Summary: This article walks you through how to implement JSON Web Token(JWT) Authentication to secure an API. Tricky concepts on access token and refresh token are demystified with how they are used to add login functionality and to protect API endpoints.

Note 🔔:

You can jump ahead to the final work, the complete API and front-end can be found on Github✩.

The Common Misconception

The most common advice is do not waste time implementing login. Now as years go by and you climb the career ladder, it is important to understand how the whole system works to elevate yourself as an architect and make more educated design decisions. Authentication is one of the areas you need to understand how it consolidates into a system. More reasons why you should know authentication is listed in this blog.

When thinking about authentication, the common mental picture people have is a login HTML page submitting data to a backend API that cross checks submitted data with what is in the DB. While that is true for the bare bones of an authentication system, there is finer details that need to be done right which we would discuss in the rest of the blog.

Table of Contents

  1. Getting started
  2. What we will do
  3. Conclusion

Getting Started

This is a coding tutorial. And to save length of the article better spent on the "fun stuff", there is a starting repo from Github to base your work on. Change into server directory which is what we need in this blog.

Note: This article assumes you already have NodeJS installed in your computer.

Project Dependencies

The following third-party dependencies are used:

  • Express - A web framework hosted on NodeJS runtime, to handling requests coming to our server.
  • Express Validator - To run server-side validation.
  • Bcryptjs - To crumble plain text into irreversible hashes.
  • Cookie Parser - To parse cookies in incoming HTTP request.
  • Cors - To configure our backend to understand CORS protocol.
  • Dotenv - To load variables declared in .env file into the project's environment.
  • Jsonwebtoken - To facilitate token-based authentication, i.e access and refresh token.
  • Mongoose - An object modelling tool for MongoDB to enforce a specific mongoDB schema.
  • Nodemailer - To send emails from NodeJs application.
  • Nodemon - To restart server app whenever file changes occur.

These dependencies are already listed in package.json of the starter repo. To include them in your project, ensure you are in server directory root and run npm install from the terminal.

Starter repository has some steps covered:

  1. Database integration (in part 2)
  2. Cross Origin Resource Sharing (in part 3)
  3. Express.js error handling (in part 4)

What we will do

An overview outline:

  1. Declare environment variables in .env file that will be loaded into application environment.
  2. Create User database model to represent a user in our application.
  3. Add email service to send emails from our application.
  4. Create validators to validate data coming from client.
  5. Create authentication middleware to check for authentication on protected routes.
  6. Create controller functions to handle HTTP request and process a response for a matched route.
  7. Create routes. So a specific functionality is provided at each route.
  8. Register routes, So internal routing mechanism can match HTTP request to a route.

Directory structure

Final directory structure we will have:

📂server/
    ├── 📄.env
    └── 📂src/
        ├── 📄index.js
+       ├── 📂models/ 📄User.js
+       ├── 📂services/
+       │   └── 📂email/
+       │       ├── 📄email.js
+       │       └── 📄sendEmail.js
+       ├── 📂validators/
+       │   ├── 📄index.js
+       │   ├── 📄auth-validators.js
+       │   └── 📄user-validators.js
+       ├── 📂middlewares/ 📄authCheck.js
+       ├── 📂controllers/
+       │   └── 📂user/
+       │       ├── 📄index.js
+       │       ├── 📄auth.controller.js
+       │       └── 📄user.controller.js
+       ├── 📂routes/
+       │   ├── 📄index.js
+       │   └── 📄user.routes.js
        ├── 📂config/
        │   └── ...
        └── 📂dbConn/
             └── ...
Enter fullscreen mode Exit fullscreen mode

Let's dive in

Child jumps into shallow pool

  1. Declare environment variables

    Environment variables are stored in the RAM of the Operating System. A running applcation will read the variables it needs from the memory of its enclosing OS.

    We will use environment variables to store variables that are personal to you, e.g API keys and should not to be shared through code repositories like github.

    Let's declare environment variables our application will use. Create .env file at server directory root, i.e server/(refer above). Declare the variables as follows:

    AUTH_REFRESH_TOKEN_SECRET=2Kp6BjSKl3boT6+Zf3D0tw
    AUTH_REFRESH_TOKEN_EXPIRY=1d
    AUTH_ACCESS_TOKEN_SECRET=bkZaWfqssqzE0fg1TMCHqQ
    AUTH_ACCESS_TOKEN_EXPIRY=120s
    RESET_PASSWORD_TOKEN_EXPIRY_MINS=15
    

    For the "TOKEN_SECRETs", you can input any value. Random characters is ideal. NodeJs can help generate random characters on the fly; In your terminal run: node -e "console.log(crypto.randomBytes(32).toString('base64'))".

    Realize how we set expiration time of our authentication tokens i.e Refresh Token expiration time(AUTH_REFRESH_TOKEN_EXPIRY) is longer than Access Token's (AUTH_ACCESS_TOKEN_EXPIRY). Also, the Access Token expiration time is reasonably short.

    AUTH_REFRESH_TOKEN_SECRET - Holds value of the secret to sign JWT Refresh Token.

    AUTH_REFRESH_TOKEN_EXPIRY - Holds value of the expiration time of the JWT Refresh Token.

    AUTH_ACCESS_TOKEN_SECRET - Holds value of the secret to sign JWT Access Token.

    AUTH_ACCESS_TOKEN_EXPIRY - Holds value of the expiration time of the JWT Access Token.

    RESET_PASSWORD_TOKEN_EXPIRY_MINS - Expiry time of the password reset link.

    Note: .env file declares environment variables in plain text(it does not load them into OS environment). We will use dotenv library to read variables in .env file and actually load them into our application's environment(OS). Also be sure to add .env file to your .gitignore to avoid sharing your .env variables.

  2. Create User database model

    A database model, is an interface for interacting with the database to query for operations like reading, creating, updating and deleting records.

    User model will represent a user of our application. This is the only model we will need for this tutorial. We'll use Mongoose to create this model.

    Create a file User.js at the path: server/src/models/(refer). Paste the following code in this file:

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const jwt = require("jsonwebtoken");
    const crypto = require("crypto");
    
    const CustomError = require("../config/errors/CustomError");
    
    // Pull in Environment variables
    const ACCESS_TOKEN = {
        secret: process.env.AUTH_ACCESS_TOKEN_SECRET,
        expiry: process.env.AUTH_ACCESS_TOKEN_EXPIRY,
    };
    const REFRESH_TOKEN = {
        secret: process.env.AUTH_REFRESH_TOKEN_SECRET,
        expiry: process.env.AUTH_REFRESH_TOKEN_EXPIRY,
    };
    const RESET_PASSWORD_TOKEN = {
        expiry: process.env.RESET_PASSWORD_TOKEN_EXPIRY_MINS,
    };
    
    /* 
    1. CREATE USER SCHEMA
    */
    
    /* 
    2. SET SCHEMA OPTION
    */
    
    /* 
    3. ATTACH MIDDLEWARE
    */
    
    /* 
    4. ATTACH CUSTOM STATIC METHODS
    */
    
    /* 
    5. ATTACH CUSTOM INSTANCE METHODS
    */
    
    /* 
    6. COMPILE MODEL FROM SCHEMA
    */
    
    module.exports = UserModel;
    

    The snippet above imports dependencies we will use. We have pulled in environment variables, available in process.env and assigned them to constants for ease of access. We have 6 parts(commented) in the code snippet above.

    Under the 1st part(1. CREATE USER SCHEMA), add the following code:

    const User = mongoose.Schema;
    const UserSchema = new User({
        firstName: { type: String, required: [true, "First name is required"] },
        lastName: { type: String, required: [true, "Last name is required"] },
        email: {
            type: String,
            required: [true, "Email is required"],
            unique: true,
        },
        password: {
            type: String,
            required: true,
        },
        tokens: [
            {
                token: { required: true, type: String },
            },
        ],
        resetpasswordtoken: String,
        resetpasswordtokenexpiry: Date,
    });
    

    We have defined the schema for a user in our application. In other words, the attributes a user entity will have. Hence users stored in the database will be expected to each have these attributes. Some are optional while others are required.

    Note that we have defined tokens in the database schema where we will store refresh tokens generated for a user.

    In the 2nd part(2. SET SCHEMA OPTION), add the following code:

    UserSchema.set("toJSON", {
        virtuals: true,
        transform: function (doc, ret, options) {
            delete ret.password;
            delete ret.tokens;
            return ret;
        },
    });
    

    This ensures everytime we retrieve a document from User model, we delete some fields that we do not wish to be included in the result.

    In the 3rd part(3. ATTACH MIDDLEWARE), add the following code:

    UserSchema.pre("save", async function (next) {
        try {
            if (this.isModified("password")) {
                const salt = await bcrypt.genSalt(10);
                this.password = await bcrypt.hash(this.password, salt);
            }
            next();
        } catch (error) {
            next(error);
        }
    });
    

    This is a mongoose middleware that runs before a document is saved to the database. And it ensures that password attribute of a user is hashed if it was modified. Therefore, when a user is newly created, password of the user is hashed. The next time it is hashed once again is only when a user's password attribute is changed. Probably when a user has updated their password.

    In the following parts, we have custom methods we are setting on the UserSchema. They will be available on the User model and are helpful in avoiding redundant code from the controllers.

    Under the 4th part(4. ATTACH CUSTOM STATIC METHODS), add the following code:

    UserSchema.statics.findByCredentials = async (email, password) => {
        const user = await UserModel.findOne({ email });
        if (!user)
            throw new CustomError(
                "Wrong credentials!",
                400,
                "Email or password is wrong!"
            );
        const passwdMatch = await bcrypt.compare(password, user.password);
        if (!passwdMatch)
            throw new CustomError(
                "Wrong credentials!!",
                400,
                "Email or password is wrong!"
            );
        return user;
    };
    

    We have created a static method that is invokable directly on User model rather than its instance, enabled by the statics property on the UserSchema. This method simply finds a user by email and password. It throws an exception if the find is not successful.

    Note how we generate user-defined exception using custom error constructor(throw new CustomError(...)). That is explained in Express.js error handling. Otherwise you can use the in-built new Error().

    In the 5th part(5. ATTACH CUSTOM INSTANCE METHODS), we will add three instance methods that will be invokable on the User model instance:

    Add the first instance method:

    UserSchema.methods.generateAcessToken = function () {
        const user = this;
    
        // Create signed access token
        const accessToken = jwt.sign(
            {
                _id: user._id.toString(),
                fullName: `${user.firstName} ${user.lastName}`,
                email: user.email,
            },
            ACCESS_TOKEN.secret,
            {
                expiresIn: ACCESS_TOKEN.expiry,
            }
        );
    
        return accessToken;
    };
    

    This instance method generates an access token. The access token is a signed jwt embedded with user instance data.

    Note: We use traditional functions because they have this binding. Using arrow functions will not produce expected results.

    Add the second instance method:

    UserSchema.methods.generateRefreshToken = async function () {
        const user = this;
    
        // Create signed refresh token
        const refreshToken = jwt.sign(
            {
                _id: user._id.toString(),
            },
            REFRESH_TOKEN.secret,
            {
                expiresIn: REFRESH_TOKEN.expiry,
            }
        );
    
        // Create a 'refresh token hash' from 'refresh token'
        const rTknHash = crypto
            .createHmac("sha256", REFRESH_TOKEN.secret)
            .update(refreshToken)
            .digest("hex");
    
        // Save 'refresh token hash' to database
        user.tokens.push({ token: rTknHash });
        await user.save();
    
        return refreshToken;
    };
    

    Similar to first instance method, this method generates a refresh token which is a signed jwt embedded with user instance data. We store the refresh token in the DB. It will help implememt a log out from all devices feature as seen later in the blog.

    Note: We store a hashed version of the refresh token in the database which is a security practice to prevent changing users' password should the database be compromised.

    Add the third instance method:

    UserSchema.methods.generateResetToken = async function () {
        const resetTokenValue = crypto.randomBytes(20).toString("base64url");
        const resetTokenSecret = crypto.randomBytes(10).toString("hex");
        const user = this;
    
        // Separator of `+` because generated base64url characters doesn't include this character
        const resetToken = `${resetTokenValue}+${resetTokenSecret}`;
    
        // Create a hash
        const resetTokenHash = crypto
            .createHmac("sha256", resetTokenSecret)
            .update(resetTokenValue)
            .digest("hex");
    
        user.resetpasswordtoken = resetTokenHash;
        user.resetpasswordtokenexpiry =
            Date.now() + (RESET_PASSWORD_TOKEN.expiry || 5) * 60 * 1000; // Sets expiration age
    
        await user.save();
    
        return resetToken;
    };
    

    This instance method creates a reset token and a reset token hash. We use NodeJs native crypto module to create resetTokenValue and resetTokenSecret. A resetToken is obtained by concatenating these parts. We also create a resetTokenHash from these parts. We return plaintext resetToken while resetTokenHash is saved to the database. In the database, we also set an expiry to the reset token so that it is expired after a duration or after use.

    The first part(resetTokenValue) is created with base64url encoding. The second part(resetTokenSecret) is created with hex encoding. These encodings allow to concatenate the two parts, to form a resultant resetToken. A plus(+) separator is specially used; since both of these encodings will never generate the + character.

    hex encoding may be straight forward, i.e represented by only 16 symbols(0-9 and A-F). Learn more about base64url encoding here.

    In the 6th part(6. COMPILE MODEL FROM SCHEMA): we compile model from schema:

    const UserModel = mongoose.model("User", UserSchema);
    
  3. Add email service

    Our application needs to send emails, an example usecase is sending password reset link to a user.
    We will use nodemailer as a Mail User Agent(MUA) to send emails from our NodeJs application; by connecting to an SMTP server.

    We will use Mailtrap as our SMTP server, responsible to deliver emails.

    Mailtrap can be used for email sending. Apart from that, it offers a feature great for testing, that is, it can be used as fake SMTP server. This means we can use Mailtrap's SMTP server to receive emails from our NodeJs application and emulate sending without actual delivery. This service is ideal to test and view emails we send without spamming real recipients or flooding your own inboxes.

    Later on if you want to send emails to real inboxes, you can update on your Mailtrap dashboard to do actual email delivery. Or you may as well change configuration options in nodemailer with another provider such as sendgrid.

    You will need to get credentials from mailtrap to use their SMTP service. Follow this guide to get credentials for SMTP integration.

    After following the guide, you should have the following credentials:

    1. Host
    2. Port
    3. Username
    4. Password

    We will add Host and Port directly to nodemailer configuration options. Meanwhile, we add Username and Password to environment variables as these will be secret and unique to an individual's account.

    Open .env file located at server/ directory root(refer above). Append new variables:

    AUTH_EMAIL_USERNAME=<Username>
    AUTH_EMAIL_PASSWORD=<Password>
    EMAIL_FROM=<Email sender>
    

    EMAIL_FROM is the sender in an email delivery. You can fill with your own email.

    Create the file: email.js. According to the directory structure for this tutorial(refer above), it should be at the path: server/src/services/email/.

    Paste in the following code:

    const nodemailer = require("nodemailer");
    
    // Pull in Environments variables
    const EMAIL = {
        authUser: process.env.AUTH_EMAIL_USERNAME,
        authPass: process.env.AUTH_EMAIL_PASSWORD,
    };
    
    async function main(mailOptions) {
        // Create reusable transporter object using the default SMTP transport
        const transporter = nodemailer.createTransport({
            host: "smtp.mailtrap.io",
            port: 2525,
            auth: {
                user: EMAIL.authUser,
                pass: EMAIL.authPass,
            },
        });
    
        // Send mail with defined transport object
        const info = await transporter.sendMail({
            from: mailOptions?.from,
            to: mailOptions?.to,
            subject: mailOptions?.subject,
            text: mailOptions?.text,
            html: mailOptions?.html,
        });
    
        return info;
    }
    
    module.exports = main;
    

    In above snippet we have exported a main() function that connects our application to Mailtrap SMTP server facilitated with nodemailer's createTransport(...). Emails from our application are forwarded to SMTP server for actual sending using nodemailer's transporter.sendMail(...). An info object is returned containing the delivery information.

    Open/create a second file sendEmail.js. It should be located at server/src/services/email/, beside email.js(refer above).

    Paste in the following code:

    const main = require("./email.js");
    
    const fixedMailOptions = {
        from: process.env.EMAIL_FROM,
    };
    
    function sendEmail(options = {}) {
        const mailOptions = Object.assign({}, options, fixedMailOptions);
        return main(mailOptions);
    }
    
    module.exports.sendEmail = sendEmail;
    

    Short and sweet ✔. We are calling imported main() function and passing to it mailOptions to run email sending. We then export the sendEmail() that will be used in our application.

  4. Create validators

    Form data submitted from client to server cannot be trusted. We need to enforce validation rules for these data before we can start processing them in our server.

    For server-side validation, we'll use express-validator which according to the docs:

    express-validator is a set of express.js middlewares that wraps validator.js validator and sanitizer functions.

    Being wrapped in express.js middlewares, means it would be easy to work with in an express.js application.

    Let's create our first set of validators.

    Create a file auth-validators.js located at server/src/validators/(refer above). Add the following code in it:

    const { body, param } = require("express-validator");
    
    const User = require("../models/User");
    
    module.exports.loginValidator = [
        body("email")
            .trim()
            .notEmpty()
            .withMessage("Email CANNOT be empty")
            .bail()
            .isEmail()
            .withMessage("Email is invalid"),
        body("password").notEmpty().withMessage("Password CANNOT be empty"),
    ];
    
    module.exports.signupValidator = [
        body("firstName")
            .trim()
            .notEmpty()
            .withMessage("Firstname CANNOT be empty"),
        body("lastName")
            .trim()
            .notEmpty()
            .withMessage("Lastname CANNOT be empty"),
        body("email")
            .trim()
            .notEmpty()
            .withMessage("Email CANNOT be empty")
            .bail()
            .isEmail()
            .withMessage("Email is invalid")
            .bail()
            .custom(async (email) => {
                // Finding if email exists in Database
                const emailExists = await User.findOne({ email });
                if (emailExists) {
                    throw new Error("E-mail already in use");
                }
            }),
        body("password")
            .notEmpty()
            .withMessage("Password CANNOT be empty")
            .bail()
            .isLength({ min: 4 })
            .withMessage("Password MUST be at least 4 characters long"),
    ];
    
    module.exports.forgotPasswordValidator = [
        body("email")
            .trim()
            .notEmpty()
            .withMessage("Email CANNOT be empty")
            .bail()
            .isEmail()
            .withMessage("Email is invalid"),
    ];
    
    module.exports.resetPasswordValidator = [
        param("resetToken").notEmpty().withMessage("Reset token missing"),
        body("password")
            .notEmpty()
            .withMessage("Password CANNOT be empty")
            .bail()
            .isLength({ min: 4 })
            .withMessage("Password MUST be at least 4 characters long"),
        body("passwordConfirm").custom((value, { req }) => {
            if (value !== req.body.password) {
                throw new Error("Passwords DO NOT match");
            }
    
            return true;
        }),
    ];
    

    The code snippet above exports validation rules to be used on requests carrying data received from client-side forms. These are rules about a user's authentication.

    For the second set of validators, create user-validators.js located at server/src/validators/(refer above).

    And paste in the following code:

    const { param } = require("express-validator");
    
    module.exports.fetchUserProfileValidator = [
        param("id").notEmpty().withMessage("User id missing"),
    ];
    

    The above snippet exports validation rule(s) that act on user object in our application.

    Lastly, we would like to export all the validation rules from a single file.

    Create index.js at server/src/validators/(refer above). And paste in:

    const authValidators = require("./auth-validators");
    const userValidators = require("./user-validators");
    
    module.exports = {
        ...authValidators,
        ...userValidators,
    };
    

    Simply, we are combining all validation rules and exporting as a single object.

  5. Create authentication middleware

    An authentication middleware will intercept HTTP requests to check for authentication.

    A request is allowed to proceed to a resource after it has passed authentication check.

    Let's create this middleware. So create authCheck.js. According to directory structure(refer above), it should be at the path: server/src/middlewares/.

    Paste in the following code:

    const jwt = require("jsonwebtoken");
    
    const AuthorizationError = require("../config/errors/AuthorizationError.js");
    
    // Pull in Environment variables
    const ACCESS_TOKEN = {
        secret: process.env.AUTH_ACCESS_TOKEN_SECRET,
    };
    
    module.exports.requireAuthentication = async (req, res, next) => {
        try {
            const authHeader = req.header("Authorization");
            if (!authHeader?.startsWith("Bearer "))
                throw new AuthorizationError(
                    "Authentication Error",
                    undefined,
                    "You are unauthenticated!",
                    {
                        error: "invalid_access_token",
                        error_description: "unknown authentication scheme",
                    }
                );
    
            const accessTokenParts = authHeader.split(" ");
            const aTkn = accessTokenParts[1];
    
            const decoded = jwt.verify(aTkn, ACCESS_TOKEN.secret);
    
            // Attach authenticated user and Access Token to request object
            req.userId = decoded._id;
            req.token = aTkn;
            next();
        } catch (err) {
            // Authentication didn't go well
            console.log(err);
    
            const expParams = {
                error: "expired_access_token",
                error_description: "access token is expired",
            };
            if (err.name === "TokenExpiredError")
                return next(
                    new AuthorizationError(
                        "Authentication Error",
                        undefined,
                        "Token lifetime exceeded!",
                        expParams
                    )
                );
    
            next(err);
        }
    };
    

    Above snippet is an express middleware that checks if a request is authenticated in the following steps:

    1. The request has an access token present in the Authorization header. A Bearer token is expected, so we use the split function to get everything after the space. Any errors thrown here will end up in the catch block.
    2. Verify if the access token is valid.

    A request is considered authenticated if all goes well in the above steps. It would then be allowed to proceed to a protected resource.

    Otherwise, an exception is thrown with a custom error constructor AuthorizationError() which would return an authentication failure response regarding the HTTP specs for returning unauthorized/unauthenticated error.

  6. Create controller functions

    In this part we write controllers; the program logic for routes in our application. In express, they are also known as route handlers.

    Typically in a routing mechanism, a controller registered for a particular path/route will be run when that route matches. Below are routes in our application. We will write controllers for these route.

    1. /api/users/login - User login.
    2. /api/users/signup - User signup.
    3. /api/users/logout - User logout.
    4. /api/users/master-logout - User logout from all devices.
    5. /api/users/reauth - Refresh Access Token.
    6. /api/users/forgotpass - Forgot password.
    7. /api/users/resetpass - Reset password.
    8. /api/users/me - Get profile of logged in user.
    9. /api/users/:id - Get profile of user by Id.

    So let's get started with creating controllers.

    Create auth.controller.js. It should be at the path: server/src/controllers/user/(refer above).

    This file will contain controllers that alter user's authentication.

    Paste the following code inside the file:

    const { validationResult } = require("express-validator");
    const jwt = require("jsonwebtoken");
    const crypto = require("crypto");
    
    const User = require("../../models/User");
    const { sendEmail } = require("../../services/email/sendEmail");
    // 👇Were created in part 4
    const CustomError = require("../../config/errors/CustomError");
    const AuthorizationError = require("../../config/errors/AuthorizationError");
    
    // Top-level constants
    const REFRESH_TOKEN = {
        secret: process.env.AUTH_REFRESH_TOKEN_SECRET,
        cookie: {
            name: "refreshTkn",
            options: {
                httpOnly: false,
                sameSite: "None",
                secure: true,
                maxAge: 24 * 60 * 60 * 1000,
            },
        },
    };
    const ACCESS_TOKEN = {
        secret: process.env.AUTH_ACCESS_TOKEN_SECRET,
    };
    const RESET_PASSWORD_TOKEN = {
        expiry: process.env.RESET_PASSWORD_TOKEN_EXPIRY_MINS,
    };
    
    /*
      1. LOGIN USER
    */
    
    /*
      2. SIGN UP USER 
    */
    
    /*
      3. LOGOUT USER
    */
    
    /*
      4. LOGOUT USER FROM ALL DEVICES
    */
    
    /*
      5. REGENERATE NEW ACCESS TOKEN
    */
    
    /*
      6. FORGOT PASSWORD
    */
    
    /*
      7. RESET PASSWORD
    */
    

    The snippet above has imported modules/dependencies to aid writing logic in controller functions. Global scoped constants which are objects, are also declared. They store environment variables.

    Moreover, const REFRESH_TOKEN constant holds cookie configurations in cookie property we will use to set cookie(s) on a HTTP response.

    WHAT YOU NEED TO KNOW ABOUT COOKIES in decoupled applications communicating to each other via an API.

    By default, browsers will not send cookies in cross-origin requests. Since we are building an API that we intend to consume from client-side sitting on a different origin, we have to set some configurations on the cookie to store in a user's browser. These configurations will allow the cookie to be sent back when cross-site request to our server is made.

    These configurations are what are contained in cookie property of const REFRESH_TOKEN object. More specifically in the options property:

    const REFRESH_TOKEN = {
        // ...
        cookie: {
            name: "refreshTkn",
            options: {
                sameSite: "None",
                secure: true,
                httpOnly: true,
                maxAge: 24 * 60 * 60 * 1000,
            },
        },
    };
    

    We have set sameSite property to "None" which in a cookie context means that the browser can send the cookie with cross site requests.

    However, setting a SameSite attribute to None will also require you to set Secure attribute, implying a cookie is only sent in an encrypted request over the HTTPS protocol. Hence in cookie configurations above, we also set secure to true. Localhost is not a https protocol but it is treated special i.e cookie should still be sent even with secure=true. You can learn more about cookie sameSite attribute here.

    Another property is httpOnly set to true. In cookie context, a cookie with httponly attribute stored in the browser is inaccessible to scripts running on the browser.

    maxAge property sets the age of the cookie to expire in browser.

    In the snippet above; where we shared code for auth.controller.js, we have 7 parts numbered each in a multiline comment. We will write controller functions in parts.

    In the 1st part(1. LOGIN USER), Add the following code:

    module.exports.login = async (req, res, next) => {
        try {
            const errors = validationResult(req);
            if (!errors.isEmpty()) {
                throw new CustomError(
                    errors.array(),
                    422,
                    errors.array()[0]?.msg
                );
            }
    
            const { email, password } = req.body;
    
            /* Custom methods on user are defined in User model */
            const user = await User.findByCredentials(email, password); // Identify and retrieve user by credentials
            const accessToken = await user.generateAcessToken(); // Create Access Token
            const refreshToken = await user.generateRefreshToken(); // Create Refresh Token
    
            // SET refresh Token cookie in response
            res.cookie(
                REFRESH_TOKEN.cookie.name,
                refreshToken,
                REFRESH_TOKEN.cookie.options
            );
    
            // Send Response on successful Login
            res.json({
                success: true,
                user,
                accessToken,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    Notice🕵 that we create controllers with the signature of an express middleware, i.e (req, res, next) => {...}. Creating this way will allow us to use next() to pass error encountered here to the error handling midleware configured earlier on.

    You'll see this piece of code frequently:

    // ...
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        throw new CustomError(errors.array(), 422, errors.array()[0]?.msg);
    }
    // ...
    

    It captures validation errors on the request object. Validators are route level middlewares added before controllers that need user data validation in a route. They enforce validation rules on data in request object. Validation result including errors are aggregated on the request object, hence validation result is available in request object from our controllers. We throw an error using a CustomError constructor if data validation errors are found in request object.

    This exported controller stipulates control flow for a user login:

    1. Identify user by login credentials received.
    2. Generate Access and Refresh Tokens for the identified user.
    3. Set a cookie in response with Refresh Token as its value.
    4. Send response with Access Token in the body.

    In the 2nd part(2. SIGN UP USER), Add the following code:

    module.exports.signup = async (req, res, next) => {
        try {
            const errors = validationResult(req);
            if (!errors.isEmpty()) {
                throw new CustomError(
                    errors.array(),
                    422,
                    errors.array()[0]?.msg
                );
            }
            const { firstName, lastName, email, password } = req.body;
    
            /* Custom methods on newUser are defined in User model */
            const newUser = new User({ firstName, lastName, email, password });
            await newUser.save(); // Save new User to DB
            const accessToken = await newUser.generateAcessToken(); // Create Access Token
            const refreshToken = await newUser.generateRefreshToken(); // Create Refresh Token
    
            // SET refresh Token cookie in response
            res.cookie(
                REFRESH_TOKEN.cookie.name,
                refreshToken,
                REFRESH_TOKEN.cookie.options
            );
    
            // Send Response on successful Sign Up
            res.status(201).json({
                success: true,
                user: newUser,
                accessToken,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    The flow of user sign up:

    1. Save new user with user data from request body.
    2. Generate Access and Refresh Tokens for the new user.
    3. Set a cookie in response with Refresh Token as its value.
    4. Send response with Access Token included in the body.

    We include authentication tokens in response because presumably after a successful sign up, the frontend would want to automatically login a user.

    In the 3rd part(3. LOGOUT USER), Add the following code:

    module.exports.logout = async (req, res, next) => {
        try {
            // Authenticated user ID attached on `req` by authentication middleware
            const userId = req.userId;
            const user = await User.findById(userId);
    
            const cookies = req.cookies;
            // const authHeader = req.header("Authorization");
            const refreshToken = cookies[REFRESH_TOKEN.cookie.name];
            // Create a access token hash
            const rTknHash = crypto
                .createHmac("sha256", REFRESH_TOKEN.secret)
                .update(refreshToken)
                .digest("hex");
            user.tokens = user.tokens.filter(
                (tokenObj) => tokenObj.token !== rTknHash
            );
            await user.save();
    
            // Set cookie expiry to past date so it is destroyed
            const expireCookieOptions = Object.assign(
                {},
                REFRESH_TOKEN.cookie.options,
                {
                    expires: new Date(1),
                }
            );
    
            // Destroy refresh token cookie
            res.cookie(REFRESH_TOKEN.cookie.name, "", expireCookieOptions);
            res.status(205).json({
                success: true,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    To logout a user, we delete the Access Token (attached in req object by authentication middleware) from the user's record in the database. This will cause authentication middleware to invalidate requests that contain this access token. Then we destroy the refresh token stored in a cookie in user's browser. We destroy this cookie by passing the same configurations used to create the cookie, but this time setting cookie maxAge to 0 and a value of none("").

    In the 4th part(4. LOGOUT USER FROM ALL DEVICES), Add the following code:

    module.exports.logoutAllDevices = async (req, res, next) => {
        try {
            // Authenticated user ID attached on `req` by authentication middleware
            const userId = req.userId;
            const user = await User.findById(userId);
    
            user.tokens = undefined;
            await user.save();
    
            // Set cookie expiry to past date to mark for destruction
            const expireCookieOptions = Object.assign(
                {},
                REFRESH_TOKEN.cookie.options,
                {
                    expires: new Date(1),
                }
            );
    
            // Destroy refresh token cookie
            res.cookie(REFRESH_TOKEN.cookie.name, "", expireCookieOptions);
            res.status(205).json({
                success: true,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    It is the same process as logout controller. Only this time, we destroy all tokens in a user's record.

    In the 5th part(5. REGENERATE NEW ACCESS TOKEN), Add the following code:

    module.exports.refreshAccessToken = async (req, res, next) => {
        try {
            const cookies = req.cookies;
            const authHeader = req.header("Authorization");
    
            if (!cookies[REFRESH_TOKEN.cookie.name]) {
                throw new AuthorizationError(
                    "Authentication error!",
                    "You are unauthenticated",
                    {
                        realm: "reauth",
                        error: "no_rft",
                        error_description: "Refresh Token is missing!",
                    }
                );
            }
            if (!authHeader?.startsWith("Bearer ")) {
                throw new AuthorizationError(
                    "Authentication Error",
                    "You are unauthenticated!",
                    {
                        realm: "reauth",
                        error: "invalid_access_token",
                        error_description: "access token error",
                    }
                );
            }
    
            const accessTokenParts = authHeader.split(" ");
            const staleAccessTkn = accessTokenParts[1];
    
            const decodedExpiredAccessTkn = jwt.verify(
                staleAccessTkn,
                ACCESS_TOKEN.secret,
                {
                    ignoreExpiration: true,
                }
            );
    
            const rfTkn = cookies[REFRESH_TOKEN.cookie.name];
            const decodedRefreshTkn = jwt.verify(rfTkn, REFRESH_TOKEN.secret);
    
            const userWithRefreshTkn = await User.findOne({
                _id: decodedRefreshTkn._id,
                "tokens.token": staleAccessTkn,
            });
            if (!userWithRefreshTkn) {
                throw new AuthorizationError(
                    "Authentication Error",
                    "You are unauthenticated!",
                    {
                        realm: "reauth",
                    }
                );
            }
            // Delete the stale access token
            console.log(
                "Removing Stale access tkn from DB in refresh handler..."
            );
            userWithRefreshTkn.tokens = userWithRefreshTkn.tokens.filter(
                (tokenObj) => tokenObj.token !== staleAccessTkn
            );
            await userWithRefreshTkn.save();
            console.log("...Tkn removED!");
    
            // GENERATE NEW ACCESSTOKEN
            const accessToken = await userWithRefreshTkn.generateAcessToken();
    
            // Send back new created accessToken
            res.status(201);
            res.set({ "Cache-Control": "no-store", Pragma: "no-cache" });
            res.json({
                success: true,
                accessToken,
            });
        } catch (error) {
            console.log(error);
            if (error?.name === "JsonWebTokenError") {
                return next(
                    new AuthorizationError(error, "You are unauthenticated", {
                        realm: "reauth",
                        error_description: "token error",
                    })
                );
            }
            next(error);
        }
    };
    

    The refreshAccessToken controller requires an Access Token in the authorization header regardless if it is expired. We throw an authorization error using AuthorizationError() custom error constructor.

    The snippet above refreshes an Access Token with a new one in the following steps:

    1. Find Refresh Token in cookie
    2. Find Access Token in Authorization header
    3. Verify Access Token. Expired jwt passes verification.
    4. Verify Refresh Token. Fails verification if expired.
    5. Check user in Refresh Token exists in our database and was assigned Access Token received here.
    6. Remove stale access token from user's record.
    7. Generate new Access token and return in response body.

    In the 6th part(6. FORGOT PASSWORD), Add the following code:

    module.exports.forgotPassword = async (req, res, next) => {
        try {
            const errors = validationResult(req);
            if (!errors.isEmpty()) {
                throw new CustomError(errors.array(), 422);
            }
    
            const email = req.body.email;
    
            const user = await User.findOne({ email });
            if (!user) throw new CustomError("Email not sent", 404);
    
            let resetToken = await user.generateResetToken();
            resetToken = encodeURIComponent(resetToken);
    
            const resetPath = req.header("X-reset-base");
            const origin = req.header("Origin");
    
            const resetUrl = resetPath
                ? `${resetPath}/${resetToken}`
                : `${origin}/resetpass/${resetToken}`;
            console.log("Password reset URL: %s", resetUrl);
    
            const message = `
                <h1>You have requested to change your password</h1>
                <p>You are receiving this because someone(hopefully you) has requested to reset password for your account.<br/>
                  Please click on the following link, or paste in your browser to complete the password reset.
                </p>
                <p>
                  <a href=${resetUrl} clicktracking=off>${resetUrl}</a>
                </p>
                <p>
                  <em>
                    If you did not request this, you can safely ignore this email and your password will remain unchanged.
                  </em>
                </p>
                <p>
                <strong>DO NOT share this link with anyone else!</strong><br />
                  <small>
                    <em>
                      This password reset link will <strong>expire after ${
                            RESET_PASSWORD_TOKEN.expiry || 5
                        } minutes.</strong>
                    </em>
                  <small/>
                </p>
            `;
    
            try {
                await sendEmail({
                    to: user.email,
                    html: message,
                    subject: "Reset password",
                });
    
                res.json({
                    message:
                        "An email has been sent to your email address. Check your email, and visit the link to reset your password",
                    success: true,
                });
            } catch (error) {
                user.resetpasswordtoken = undefined;
                user.resetpasswordtokenexpiry = undefined;
                await user.save();
    
                console.log(error.message);
                throw new CustomError("Email not sent", 500);
            }
        } catch (err) {
            next(err);
        }
    };
    

    The code above emails a user with with a password reset link.

    This is a sample email sent to a user:


    sample reset email message


    Password reset email

    In the 7th part(7. RESET PASSWORD), Add the following code:

    module.exports.resetPassword = async (req, res, next) => {
        try {
            console.log("req.params: ", req.params);
            const errors = validationResult(req);
            if (!errors.isEmpty()) {
                throw new CustomError(errors.array(), 422);
            }
    
            const resetToken = new String(req.params.resetToken);
    
            const [tokenValue, tokenSecret] =
                decodeURIComponent(resetToken).split("+");
    
            console.log({ tokenValue, tokenSecret });
    
            // Recreate the reset Token hash
            const resetTokenHash = crypto
                .createHmac("sha256", tokenSecret)
                .update(tokenValue)
                .digest("hex");
    
            const user = await User.findOne({
                resetpasswordtoken: resetTokenHash,
                resetpasswordtokenexpiry: { $gt: Date.now() },
            });
            if (!user) throw new CustomError("The reset link is invalid", 400);
            console.log(user);
    
            user.password = req.body.password;
            user.resetpasswordtoken = undefined;
            user.resetpasswordtokenexpiry = undefined;
    
            await user.save();
    
            // Email to notify owner of the account
            const message = `<h3>This is a confirmation that you have changed Password for your account.</h3>`;
            // No need to await
            sendEmail({
                to: user.email,
                html: message,
                subject: "Password changed",
            });
    
            res.json({
                message: "Password reset successful",
                success: true,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    Snippet above is the logic for a password reset. This controller expects an incoming request to have a valid resetToken. A resetTokenHash is recreated from the parts that were used to create the resetToken i.e const [tokenValue, tokenSecret] = decodeURIComponent(resetToken).split("+");.

    A user with the recreated resetTokenHash is retrieved and password is updated.

    Remember: We do not hash the password because we attached a mongoose middleware on User model that hashes the password field if it is modified.

    After modifying the password successfully, we send an email to notify about a successful password change.

    So we have created the first set of controller functions in auth.controller.js file that alter user's authentication either directly or indirectly.

    Next we need to create the second set of controller functions that act on user object in our application.

    Open/create user.controller.js. According to the directory structure for this tutorial, it should be at the path: server/src/controllers/user/(refer above). Create the constituent directories if you do not have.

    This file will contain controllers that act on user object in our application.

    Paste in the below code inside this file:

    const { validationResult } = require("express-validator");
    
    const CustomError = require("../../config/errors/CustomError");
    const User = require("../../models/User");
    
    /* 
      1. FETCH USER PROFILE BY ID
    */
    module.exports.fetchUserProfile = async (req, res, next) => {
        try {
            const errors = validationResult(req);
            if (!errors.isEmpty()) {
                throw new CustomError(
                    errors.array(),
                    422,
                    errors.array()[0]?.msg
                );
            }
    
            const userId = req.params.id;
            const retrievedUser = await User.findById(userId);
    
            res.json({
                success: true,
                user: retrievedUser,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    
    /* 
      2. FETCH PROFILE OF AUTHENTICATED USER
    */
    module.exports.fetchAuthUserProfile = async (req, res, next) => {
        try {
            const userId = req.userId;
            const user = await User.findById(userId);
    
            res.json({
                success: true,
                user,
            });
        } catch (error) {
            console.log(error);
            next(error);
        }
    };
    

    This is straight forward. We have two functions that fetch user profile. The first one fetchUserProfile(), fetches a user by ID provided in URL. And the second one fetchAuthUserProfile, fetches a user by ID gotten from authentication middleware as req.userId.

    We have so far created and exported controller functions in separate files. Let's export all these controllers from a single file so that we could import them in other modules with one import statement.

    For this, open/create a third file index.js, at the location server/src/controllers/user/(refer above).

    Add the code below in this file:

    module.exports = {
        ...require("./auth.controller"),
        ...require("./user.controller"),
    };
    

    We export one object that includes all exports from the imported files.

  7. Create application routes

    So far, we have written controllers. Now we need to create routes in our application. Basically, we will specify route paths and the controller to execute program logic at that path.

    For this, open/create user.routes.js, at the location server/src/routes/(refer above). Create the constituent directories if you do not have.

    Add the code below in this file:

    const express = require("express");
    
    const validators = require("../validators");
    const userControllers = require("../controllers/user");
    const { requireAuthentication } = require("../middlewares/authCheck");
    
    const router = express.Router();
    
    // User Login
    router.post("/login", validators.loginValidator, userControllers.login);
    
    // User Signup
    router.post("/signup", validators.signupValidator, userControllers.signup);
    
    // User Logout
    router.post("/logout", requireAuthentication, userControllers.logout);
    
    // User Logout from all devices
    router.post(
        "/master-logout",
        requireAuthentication,
        userControllers.logoutAllDevices
    );
    
    // Refresh Access Token
    router.post("/reauth", userControllers.refreshAccessToken);
    
    // Forgot password
    router.post(
        "/forgotpass",
        validators.forgotPasswordValidator,
        userControllers.forgotPassword
    );
    
    // Reset password
    router.patch(
        "/resetpass/:resetToken",
        validators.resetPasswordValidator,
        userControllers.resetPassword
    );
    
    // Authenticated user profile
    router.get(
        "/me",
        requireAuthentication,
        userControllers.fetchAuthUserProfile
    );
    
    // Get user by ID
    router.get(
        "/:id",
        requireAuthentication,
        validators.fetchUserProfileValidator,
        userControllers.fetchUserProfile
    );
    
    module.exports = router;
    

    The code above contains the routes our application will have. We used express.Router() to organize application routes into a separate file.

    Using the classic router object, we define routes in the order of HTTP Method, route path and controller, i.e router.METHOD("PATH", CONTROLLER). In between route path and controller, we can add middleware(s) that intercept a request and take it through a round of processing. So controllers are declared last in the chain.

    For instance, from the snippet above, Authentication middleware(requireAuthentication) and validators(validators.x) are middlewares that intercept a request before it is processed in controller(userControllers.x). Authentication middleware ensures request is authenticated and validators apply validation rules on data within a request.

    Since the routes we have defined are all concerned with the user object in our application, let's define a base path for these routes.

    Open/create index.js, at the location server/src/routes/(refer above).

    Add the code below in this file:

    const express = require("express");
    const router = express.Router();
    
    const userRoutes = require("./user.routes");
    
    router.use("/users", userRoutes);
    
    module.exports = router;
    

    The snippet above makes userRoutes available on /users base path. With this pattern, if your application has another user type e.g admin, you can create another base path for it.

    We then export router object created here.

  8. Register routes

    We need to register routes we have created so that traffic can be chanelled to them.

    Therefore we need to include these routes in the entry file of our application. Open this file, index.js located at server directory root, i.e server/(refer above).

    Edit the 3rd part of this file like shown below:

    const routes = require("./routes"); // import the modular routes at the top
    
    // ...
    /* 
    3. APPLICATION ROUTES 🛣️
    */
    app.use("/api", routes); // register modular routes
    // ...
    

    We have imported the routes and regisered them on a base path, "/api". Therefore in the long run, user routes will be available on a path /api/users/.... For example to access login route, the path is /api/users/login.

Conclusion

Cheers🥂, we have reached the end. Well, the article is an epic ride🏇 with valuable information 🤯. I suppose it is a divine touch🪄 to escalate your skills a level higher. Nonetheless, we have built a backend furnished with full user registration and user authentication. I have always felt that many beginners find it hard to connect the dots using JWTs for authentication, let alone implementing basic security practices when using them.

Security is an expansive topic. While that is true, in this article we have have implemented some basic security practices when using authentication tokens for authentication, i.e access tokens and refresh tokens. For example:

  • An access token is short-lived unlike the refresh token.
  • Access token is included in a response body and refresh token is added in httpOnly cookie in response.
  • When we receive access token from client, we check to ensure the token was actually issued to this user.
  • We store access token in database so that we can be able to invalidate it on demand.

It is a good thing we have built an authentication API. However for many, I know it can still be overwhelming how to consume such an API to stitch together an authentication that "works" from the frontend. If this sounds like you, be sure to check the next parts where we build the front-end using react.


Let's connect @twitter. Till then...peace✌.

Top comments (3)

Collapse
 
samintunnus profile image
Sami

There is couple things that confuses me:

Refresh token is hashed and saved to database, in the UserSchema.methods.generateRefreshToken.

In the authentication middleware module.exports.requireAuthentication, accestoken is taken from the headers, decoded and attached to the request.

But then for the logout you are saying: "To logout a user, we delete the Access Token (attached in req object by authentication middleware) from the user's record in the database." At which point is the access token saved to the user's record in the database?

And also after that you are saying: "This will cause authentication middleware to invalidate requests that contain this access token.". I can't see anything like that in the authentication middleware. Isn't it in the authentication middleware only looking at the access token in the request headers? And it will only fails if the jwt.verify fails and nothing related to the saved tokens in database?

What am I missing there?

And to add to the confusion, in the module.exports.refreshAccessToken you are finding user with the stale access token, but isn't those tokens saved in the user tokens suppose to be the refresh tokens? Event the const is named as a userWithRefreshTkn.

const userWithRefreshTkn = await User.findOne({
_id: decodedRefreshTkn._id,
"tokens.token": staleAccessTkn,
});

And there you are saying: "6. Remove stale access token from user's record." But I can't find the point where was the access token saved to user's record?

Looks to me you have mixed up access token and refresh token in your head at some point?

Collapse
 
smitterhane profile image
Smitter

Thanks for pointing out, actually in early stage of the source code, access token was being saved to the database on user sign up. And authentication middleware will test for a match between the access token in the request header versus the one in the DB.
The problem with this method is that it beats the logic of stateless authenication where there should be no DB lookups in the authentication middleware. So I changed the source code to depict pure stateless authentication and forgot to change some parts of the article like you have mentioned.

I shall update the article to 100% confer with what the source code in github is doing; answering "the why" and "how".

Collapse
 
harsh9o9 profile image
Harsh Bhardwaj

Could you kindly verify if the article is current? I've noticed some inconsistencies between the code mentioned and the code available in the associated GitHub repository.