OAuth2 is a popular authentication protocol that allows third-party applications to grant limited access to a user’s account on another application without exposing the user’s password. In this tutorial, we will build a robust OAuth2 authentication microservice using Node.js, MySQL, and Passport.

Introduction Link to heading

Creating a dedicated authentication microservice can greatly enhance the security and scalability of your application ecosystem. By centralizing authentication, you can manage user credentials, access tokens, and client applications in a single place, ensuring consistent security policies across your services. Our goal in this tutorial is to develop a reusable authentication service that other applications can integrate with ease. This authentication microservice will handle user registration, login, and token issuance using OAuth2. We will use MySQL as our database to store user, client, and token information. Sequelize will be used for ORM, making database interactions more straightforward. Passport.js will be our middleware for handling authentication strategies. Let’s get started by setting up our database structure, models, and migrations.

Database Structure and Models Link to heading

First, we need to set up our MySQL database. Here are the SQL commands to create the necessary tables:

CREATE DATABASE auth_service;

USE auth_service;

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL,
    mobile VARCHAR(255),
    password VARCHAR(255),
    rememberToken VARCHAR(255),
    mobile_verified_at VARCHAR(255),
    email_verified_at VARCHAR(255),
    ip VARCHAR(255),
    country VARCHAR(255),
    platform ENUM('android', 'ios', 'other'),
    verify TINYINT(1) DEFAULT 0,
    block TINYINT(1) DEFAULT 0,
    has_profile TINYINT(1) DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL DEFAULT NULL
);

CREATE TABLE clients (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    applicationName VARCHAR(255) UNIQUE,
    clientId VARCHAR(255) NOT NULL UNIQUE,
    clientSecret VARCHAR(255) NOT NULL UNIQUE,
    grants VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE tokens (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    accessId VARCHAR(255) NOT NULL UNIQUE,
    refreshId VARCHAR(255) NOT NULL UNIQUE,
    sessionId VARCHAR(255) NOT NULL UNIQUE,
    userId BIGINT NOT NULL,
    clientId VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (userId) REFERENCES users(id)
);

You can see the migrations of the database here: Migrations

And you can run it with the migration command as mentioned in package.json.

Models Link to heading

The model schemas and logic for users, clients, and tokens are defined in the models directory. You can find detailed implementations here links that implement with Sequelize ORM:

Configuring the Project Link to heading

config/config.js Link to heading

This is the main configuration file for the application:

const path = require('path');
const env = require('../helpers/Environment');

const environment = env.str('NODE_ENV', 'development');
const debugStatus = env.bool('DEBUG', true);
const debugPrefix = `auth-service-${environment}`;

if (debugStatus) {
    process.env.DEBUG = debugPrefix;
}

module.exports = {
    environment,
    appName: 'auth-service',
    debug: {
        enabled: debugStatus,
        prefix: debugPrefix,
        log: console.log,
        error: console.error
    },
    rootDir: path.resolve(''),
    sequelize: {
        config: {
            dialect: 'mysql',
            username: env.str('MYSQL_USER', 'root'),
            password: env.str('MYSQL_PASS', 'S9GJUS942aQqHgp9hGI1h@JYdzHpT7CzK76aW@xSgL9fV1Yp1B'),
            database: env.str('MYSQL_DB', 'spontify'),
            host: env.str('MYSQL_HOST', 'mysql-local'),
            port: env.num('MYSQL_PORT', 3306),
            logging: env.bool('DATABASE_LOGGING', true) ? console.log : false,
        }
    },
    server: {
        config: {
            host: env.str('SERVER_HOST', '0.0.0.0'),
            port: env.num('SERVER_PORT', 3001),
        },
        options: {
            logger: env.bool('WEB_SERVER_LOGGING', true)
        }
    },
    security: {
        jwtSignSecret: env.str('JWT_SECRET', '0vmmRxf0qcXqianKEDmm6cdng_tMrzRzFFnzB-etnuE'),
        jwtEncryption: {
            shouldEncrypt: env.bool('SHOULD_ENCRYPT_TOKENS', false),
            encryptionKey: env.str('JWE_ENCRYPTION_KEY', 'LUvvFZKeG8MTRCZLYBUHdmZoPg70hm-ZYTz6oRBCa-U')
        },
        databaseEncryptionSecret: env.str('DATABASE_ENCRYPTION_SECRET', '623b07cb53ec407ab641f6ef531301a2'),
        accessTokenTTL: env.str('ACCESS_TOKEN_TTL', '1 day'),
        refreshTokenTTL: env.str('REFRESH_TOKEN_TTL', '1 year'),
        bcryptSaltRounds: env.num('BCRYPT_SALT_ROUNDS', 10)
    },
    rateLimiter: {
        points: env.num('RATE_LIMIT_REQUESTS_NUMBER', 10000),
        duration: env.num('RATE_LIMIT_DURATION_SECONDS', 1000),
        timeoutMs: 5000,
        whiteList: env.array('RATE_LIMIT_WHITE_LIST', ['127.0.0.1', '8.8.8.8']),
        whileListRegExp: env.str('RATE_LIMIT_WHITE_LIST_REGEXP', '^172.17+$')
    },
    constants: {
        middlewareDirectory: '/middlewares'
    }
};

config/i18n.js Link to heading

This file sets up internationalization for cover and supprt multilanguage in the application. You can configure it as follows:

const i18n = require('i18n');
const path = require('path');

i18n.configure({
    locales: ['en', 'de'],
    defaultLocale: 'en',
    queryParameter: 'locale',
    directory: path.join(__dirname, '../locales')
});

module.exports = i18n;

Application Setup Link to heading

The setup of the application is organized into several key components: Sequelize for database interaction, Passport for authentication, and Express for the web server. We use a modular approach to keep the codebase clean and maintainable. Below are the setup files and explanations for each part.

Setting Up Sequelize Link to heading

setup/SequelizeSetup.js Link to heading

This file sets up Sequelize for interacting with the MySQL database:

const Sequelize = require('sequelize');
const fs = require('fs');
const path = require('path');
const { sequelize: { config } } = global._.config;
const baseFolder = path.join(global._.config.rootDir, 'models');

async function run() {
    const { database, username, password, ...rest } = config;
    const sequelize = new Sequelize(database, username, password, rest);
    const loaded = {};
    fs.readdirSync(baseFolder)
        .filter((file) => {
            return file.indexOf('.') !== 0 && file.slice(-3) === '.js';
        })
        .forEach((file) => {
            const ModelClass = require(path.join(baseFolder, file));
            const model = ModelClass.init(sequelize, Sequelize);
            loaded[model.name] = model;
        });
    return { sequelize, models: loaded };
}

module.exports = { run };

Setting Up Passport Link to heading

setup/PassportSetup.js Link to heading

This file sets up Passport for handling OAuth2 authentication:

const passport = require('passport');
const { debug } = global._.config;
const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
const BearerStrategy = require('passport-http-bearer').Strategy;
const User = require('../models/user');
const Client = require('../models/client');
const { verifyToken } = require('../oauth/TokenUtils');
const { errors: { JOSEError } } = require('jose');
const BaseError = require('../helpers/BaseError');
const { ApiError } = require('../helpers/Errors');

function run() {
    passport.use(new ClientPasswordStrategy(
        async function (clientId, clientSecret, done) {
            try {
                const { applicationName, grants } = await Client.authenticateClient(clientId, clientSecret);
                if (grants.includes('password')) {
                    done(null, { applicationName, grants, clientId, clientSecret });
                } else {
                    done(null, false);
                }
            } catch (err) {
                done(err);
            }
        }
    ));

    passport.use(new BearerStrategy(
        async function (token, done) {
            try {
                const tokenData = await verifyToken(token);
                const user = await User.findByPk(tokenData.userId);
                if (!user) {
                    return done(null, false);
                }
                return done(null, user);
            } catch (err) {
                if (err instanceof JOSEError) {
                    return done(null, false);
                }
                return done(err);
            }
        }
    ));
}

module.exports = { run };

Middleware Setup Link to heading

setup/MiddlewareSetup.js Link to heading

This file sets up any middleware required for the application:

const express = require('express');
const cors = require('cors');
const morgan = require('morgan');

async function run(app) {
    app.use(cors());
    app.use(morgan('combined'));
}

module.exports = { run };

Server Setup Link to heading

setup/ServerSetup.js Link to heading

This file sets up the Express server:

const Express = require('express');
const helmet = require('helmet');
const bodyParser = require('body-parser');
const {run: middlewareSetup} = require('./MiddlewareSetup');
const {run: passportSetup} = require('./PassportSetup');
const {run: sequelizeSetup} = require('./SequelizeSetup');
const OAuth2Server = require('oauth2-server');
const {AuthRouter, HealthRouter} = require('../routes');

async function run() {
    await sequelizeSetup();
    await passportSetup();

    return new Promise(async (resolve, reject) => {
        const app = Express();
        app.use(helmet());
        app.use(bodyParser.json());
        await middlewareSetup(app);
        const router = Express.Router();
        app.oauth2 = router.oauth2 = OAuth2Server;
        app.use(passport.initialize());
        AuthRouter(router);
        HealthRouter(router);

        // i18n
        app.use(i18n.init);

        app.use(router);
        const server = app.listen(config.port, config.host, function () {
            debug.log(`Server listening on ${config.host}:${config.port}`);
            return resolve({app, server});
        });
    });
}

module.exports = {run};

Creating Controllers Link to heading

controllers/authController.js Link to heading

Create the authentication controller to handle login, registration, and token :

const passport = require('passport');
const User = require('../models/user');
const Client = require('../models/client');
const Token = require('../models/token');
const crypto = require('crypto');

exports.register = (req, res) => {
    User.create({
        username: req.body.username,
        password: req.body.password,
        email: req.body.email
    }).then(user => {
        res.status(200).json({ message: 'User created successfully.', userId: user.id });
    }).catch(err => {
        res.status(500).json({ message: 'Error creating user.', error: err });
    });
};

exports.login = passport.authenticate('local', {
    successRedirect: '/dashboard',
    failureRedirect: '/login',
    failureFlash: true
});

exports.issueToken = (req, res) => {
    const clientId = req.body.clientId;
    const clientSecret = req.body.clientSecret;

    Client.findOne({ where: { clientId: clientId, clientSecret: clientSecret } }).then(client => {
        if (!client) {
            return res.status(401).json({ message: 'Invalid client credentials.' });
        }

        const tokenValue = crypto.randomBytes(32).toString('hex');
        const expiration = new Date();
        expiration.setDate(expiration.getDate() + 1); // Token expires in 1 day

        Token.create({
            accessToken: tokenValue,
            clientId: clientId,
            userId: req.user.id,
            expires: expiration
        }).then(token => {
            res.status(200).json({ accessToken: tokenValue });
        }).catch(err => {
            res.status(500).json({ message: 'Error creating token.', error: err });
        });
    }).catch(err => {
        res.status(500).json({ message: 'Error finding client.', error: err });
    });
};

Defining Routes Link to heading

routes/authRoutes.js Link to heading

Set up routes for user registration, login, and token issuance:

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');

router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/token', authController.issueToken);

module.exports = router;

Middleware for Authentication Link to heading

middlewares/authenticate.js Link to heading

Middleware to check if the user is authenticated:

module.exports = function(req, res, next) {
    if (req.isAuthenticated()) {
        return next();
    }
    res.redirect('/auth/login');
};

Run the application with Ecosystem Configuration for Clustering Link to heading

ecosystem.config.js Link to heading

Create an ecosystem file for PM2 to manage both app-master.js and app.js in a clustered environment:

module.exports = {
    apps: [
        {
            name: `auth-service-${ process.env.NODE_ENV || 'env' }-master`,
            script: "./app-master.js",
            instances: 1,
            autorestart: true
        },
        {
            name: `auth-service-${ process.env.NODE_ENV || 'env' }-worker`,
            script: "./app.js",
            instances: 1,
            exec_mode: 'cluster',
            autorestart: true
        }
    ]
};

app-master.js Link to heading

This file sets up the master process that manages clustering and rate limiting:

const throng = require('throng');
const rateLimiterClusterMaster = require('./rateLimiterClusterMaster'); // Assuming you have rate limiter logic here

const WORKERS = process.env.WEB_CONCURRENCY || 1;

const start = () => {
    console.log(`Starting master process with ${WORKERS} workers...`);
    rateLimiterClusterMaster();
};

throng({ workers: WORKERS, start });

Benefits of Clustering with PM2 Link to heading

Running a Node.js application in a clustered environment using PM2 has several advantages:

  • Enhanced Performance: PM2 can spawn multiple instances of your application, leveraging multi-core systems and improving performance by balancing the load across these instances.
  • High Availability: PM2’s clustering capabilities ensure that if one instance fails, another instance can continue handling requests, thus improving the reliability and availability of your service.
  • Zero Downtime Deployment: PM2 allows for zero-downtime deployments by smoothly reloading your application without interrupting the service.
  • Process Management: PM2 provides extensive process management features, including process monitoring, log management, and automatic restarts on crashes or changes.

To sum up, using PM2 for clustering helps you build a more resilient, high-performing, and scalable application.

Conclusion Link to heading

In this tutorial, we have built a robust OAuth2 authentication microservice using Node.js, MySQL, and Passport. We’ve covered setting up the database, defining models, configuring the project, and creating controllers and routes for handling user authentication. Finally, we configured PM2 to manage our application in a clustered environment, ensuring high performance and availability.