paint-brush
Developing an OAuth 2.0 Provider for a Resource Server from Scratchby@shobhit1997
10,124 reads
10,124 reads

Developing an OAuth 2.0 Provider for a Resource Server from Scratch

by Shobhit AgarwalMay 19th, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

OAuth 2.0 provides authorization flows for web and desktop applications, and mobile devices. It enables applications to get limited access to user accounts on an HTTP service. The OAuth Flow has 3 simple steps: Register to CollegeERP and add OAuth to the app. OAuth is an authorization framework that delegates the user authentication to the service that hosts the user account. It authorizes the third-party applications to access the user’s account. This approach is very similar to what Google uses.

People Mentioned

Mention Thumbnail

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Developing an OAuth 2.0 Provider for a Resource Server from Scratch
Shobhit Agarwal HackerNoon profile picture

Ever wondered how platforms like Google, Facebook, Twitter, GitHub, etc. provide OAuth functionality. How the OAuth functions, behind the scenes. What happens when you click Sign in with Google or Sign in with Facebook button. If yes, then this article is for you.

Let’s start by understanding what OAuth is:

OAuth is an authorization framework. It enables applications to get limited access to user accounts on an HTTP service. These services include Google, GitHub, Twitter, etc.
OAuth delegates the user authentication to the service that hosts the user account. It authorizes the third-party applications to access the user account. OAuth 2 provides authorization flows for web and desktop applications, and mobile devices.

I have implemented several OAuth Providers in my applications. I always wondered how can I do the same for the Application Server I created. Why do I have to create a new authentication server every time whenever I create a new application? How can I use the same information user provided in my previous application?

This article answers all these questions. I have written this article taking three college students as a reference. They are:

  • John — OAuth Provider
  • Kevin — Third-Party Application Developer (Client Application)
  • Alex — User of Kevin’s Application

John has developed an application(CollegeERP) for the college administration. It helps them manage the students and staff data. This application has an account for each member of the college and they can log in and edit their details.

Kevin wants to create an app CollegeMeet. A college-specific version of Tinder. He wants to keep the usage of the app limited to his college students. Kevin thought if he could get the details from the CollegeERP.

This would make his task would be much simplified. He could guarantee the usage of the app within the college. He contacts John and asks for assistance.

After assessing John came up with 2 approaches. First was, he could create a Rest API to authenticate to the CollegeERP server.

Kevin can authenticate his users to his CollegeMeet application using this API.

But there were certain downsides to this:

  1. It would not be safe to share the API with different students making multiple apps.
  2. The client application will take the raw username and password from the user and can save it for future use. Hence leading to password leak.
  3. The management and staff account is at risk.

John was aware of these downsides, so he came up with a second approach. This is where OAuth comes into the picture. He decided to add OAuth capability to his CollegeERP application. It will allow Kevin’s app to authorize on the user’s behalf. It asks the user for his consent.

Once the user approves, Kevin’s app can access information from the CollegeERP.

Benefits of OAuth Authorization Framework:

Users enter their credentials only on the CollegeERP login page. Hence less to no risk of password leaks.The user consents to the authorization request. If the user declines, the CollegeMeet app cannot access the user’s data.It provides limited access to data.Single point of user data update for all third-party applications.

The sequence diagram below provides a generalized overview of the OAuth Flow. I have generalized this based on multiple OAuth Providers. This approach is very similar to what Google uses.

Most of the resources on the internet are on how to integrate a certain OAuth provider(Google OAuth 2). The primary focus of this article is on creating an OAuth Provider.

For this article, John would be creating a CollegeERP simulator. This simulator provides register/login functionality taking minimal user information. He would then add the Project/Application creation functionality. Finally, he would add OAuth Functionality to allow registered and authorized apps(e.g. CollegeMeet) to access user data stored on my server.

Initial Setup

mkdir OAuth2
cd OAuth2
npm init -y
npm i express mongoose dotenv body-parser bcryptjs crypto jsonwebtoken ramda

The OAuth Flow has 3 simple steps:

  1. Kevin and Alex Register to CollegeERP
  2. Kevin registers the CollegeMeet app on CollegeERP
  3. Alex logs into CollegeMeet by clicking on Sign-in with CollegeERP

Folder Structure

//app.js
const path = require("path");
const bodyParser = require("body-parser");
const express = require("express");

const app = express();
const publicPath = path.join(__dirname, "../public");
const userRouter = require("./routes/UserRoutes");
const projectRouter = require("./routes/ProjectRoutes");
const OAuthRouter = require("./routes/OAuthRoutes");

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(
  express.static(publicPath, {
    extensions: ["html"],
  })
);

app.use(function (req, res, next) {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Expose-Headers", "x-auth");
  res.header("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE");
  res.setHeader(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With,content-type, Accept , x-auth"
  );

  next();
});

app.use("/api/user", userRouter);
app.use("/api/project", projectRouter);
app.use("/api/oauth", OAuthRouter);
module.exports = app;
//index.js
const app = require('./app');
const http = require('http');
var server = http.createServer(app);
require('dotenv').config({ path: './.env' });
const mongoose = require('./db/connectDB');
const port = process.env.PORT;
server.listen(port, function () {
    console.log("Server running at port " + port);
});

Kevin and Alex Register to CollegeERP

Kevin and Alex want to register for the CollegeERP app. John has to provide the Authentication facility in his app for this.

Authentication is the process or action of verifying the identity of a user or process. It allows you to uniquely identify each user of your application.
  1. 3 key functionalities of an Authentication Server. 
    a. Register/Signup
    b. Login
    c. Logout
  2. The first step to build an Authentication Server is to decide what data you want from the user. For eg. name, email, phone, gender, etc.
  3. Then decide on the type of authentication scheme you want to use. This includes email/username — password, phone — OTP, email — verification, etc.
  4. Generate a user model accordingly. John has used email — password authentication and stored user’s name, email, and phone data. Find below his User Model file. The method names are self-explanatory.
  5. He used the JWT authentication mechanism. A JWT is a mechanism to verify the owner of some JSON data. It’s an encoded string, which is URL safe. It can contain an unlimited amount of data (unlike a cookie), and it’s cryptographically signed.
  6. Never store the password as plain text in the DB. Always encrypt it. For this John has used the bcryptjs npm package.
  7. The pre-save hook in the user.js file performs the encryption.
  8. Next is creating 3 routes
    a. POST /register — Create a new user account
    b. POST /login — Login into an existing user account
    c. DELETE /logout — Logout from the current session
    Note: You should validate any data before saving (Though John hasn’t done since this was just a prototype.)
  9. The verifyAuthToken function is a middleware function. It retrieves the token from the request object header and finds the user by that token.
  10. Integrate the API with the frontend of your choice. Hurrah!! your authentication server is ready.
/*
 * server/models/user.js
 * User Model File
 */
const mongoose = require("mongoose");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
require("dotenv").config({ path: "./.env" });

var Schema = mongoose.Schema;

var UserSchema = new Schema({
  name: {
    type: String,
    required: true,
    trim: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
  },
  phone: {
    type: Number,
    required: true,
    unique: true,
    trim: true,
  },
  password: {
    type: String,
    minlength: 6,
  },
  createdAt: { type: Date, default: Date.now },
  tokens: [
    {
      access: {
        type: String,
        required: true,
      },
      token: {
        type: String,
        required: true,
      },
    },
  ],
});

UserSchema.methods.generateAuthToken = function () {
  var user = this;
  var access = "auth";
  var token = jwt
    .sign({ _id: user._id.toHexString(), access }, process.env.JWT_SECRET)
    .toString();
  user.tokens.push({ access, token });
  return user.save().then(function () {
    return token;
  });
};
/*
 * This function will be used later in the article in step 3 while generating
 * Authorization Code. You can ignore it for now.
 */
UserSchema.methods.generateOAuthCode = function (project) {
  var user = this;
  var access = "oauth";
  var token = jwt
    .sign(
      {
        access,
        _id: user._id.toHexString(),
        projectID: project.projectID,
        projectSecret: project.projectSecret,
        scope: project.scope,
      },
      process.env.JWT_SECRET
    )
    .toString();
  user.tokens.push({ access, token });
  return user.save().then(function () {
    return token;
  });
};
/*
 * This function will be used later in the article in step 3 while exchanging access_token
 * for Authorization Code. You can ignore it for now.
 */
UserSchema.methods.generateAccessToken = function (scope) {
  var user = this;
  var access = "access_token";
  var token = jwt
    .sign(
      { _id: user._id.toHexString(), access, scope },
      process.env.JWT_SECRET
    )
    .toString();
  user.tokens.push({ access, token });
  return user.save().then(function () {
    return token;
  });
};

UserSchema.statics.findByToken = async function (token, access) {
  var User = this;
  var decoded;

  try {
    decoded = jwt.verify(token, process.env.JWT_SECRET);
  } catch (e) {
    return Promise.reject({ code: 401, message: "Invalid Code" });
  }
  user = await User.findOne({
    _id: decoded._id,
    "tokens.token": token,
    "tokens.access": access,
  });
  return {
    decoded,
    user,
  };
};

UserSchema.statics.findByCredentials = function (email, password) {
  var User = this;
  return User.findOne({ email }).then(function (user) {
    if (!user) {
      return Promise.reject({ code: 400, message: "Invalid Credentials" });
    }
    return new Promise(function (resolve, reject) {
      bcrypt.compare(password, user.password, function (err, res) {
        if (res) {
          resolve(user);
        } else {
          reject();
        }
      });
    });
  });
};

UserSchema.pre("save", function (next) {
  var user = this;
  if (user.isModified("password")) {
    bcrypt.genSalt(10, function (err, salt) {
      bcrypt.hash(user.password, salt, function (err, hash) {
        user.password = hash;
        next();
      });
    });
  } else {
    next();
  }
});

UserSchema.methods.removeToken = function (token) {
  var user = this;
  return user.updateOne({
    $pull: {
      tokens: { token },
    },
  });
};

module.exports = mongoose.model("User", UserSchema);
/*
 * server/routes/UserRoutes.js
 * User Routes File
 */
const express = require("express");
const R = require("ramda");

const { verifyAuthToken } = require("../middlewares/authenticate");
const projectMiddleware = require("../middlewares/projectMiddleware");
const User = require("../models/user");

const router = express.Router();

router.route("/register").post(function (req, res) {
  var body = R.pick(["name", "phone", "email", "password"], req.body);
  var user = new User(body);
  user
    .save()
    .then(function () {
      return user.generateAuthToken();
    })
    .then(function (token) {
      res.header("x-auth", token).send(R.pick(["name"], user));
    })
    .catch(function (e) {
      console.log(e);
      res.status(400).send({ code: 400, message: e });
    });
});

router.route("/login").post(async function (req, res) {
  var body = R.pick(["email", "password"], req.body);
  try {
    var user = await User.findByCredentials(body.email, body.password);
    var token = await user.generateAuthToken();
    res.header("x-auth", token).send(R.pick(["name"], user));
  } catch (e) {
    console.log(e);
    res.status(400).send({ code: 400, message: e });
  }
});
router.route("/logout").delete(verifyAuthToken, function (req, res) {
  req.user
    .removeToken(req.token)
    .then(function () {
      res.send({ message: "Logout Successfull" });
    })
    .catch(function (e) {
      console.log(e);
      res.status(400).send({ code: 400, message: e });
    });
});

module.exports = router;
/*
 * server/middlewares/authenticate.js
 * Middleware Functions File
 */
const User = require("../models/user");
/*
 * This function takes the x-auth token from header, validates it,
 * and finds the user by that.
 */
var verifyAuthToken = function (req, res, next) {
  var token = req.header("x-auth");
  User.findByToken(token, "auth")
    .then(function (data) {
      if (!data.user) {
        return Promise.reject({ code: 401, message: "Invalid X-Auth Token" });
      }
      req.user = data.user;
      req.token = token;
      next();
    })
    .catch(function (e) {
      if (e.code) {
        res.status(e.code).send(e);
      } else {
        console.log(e);
        res.status(500).send({ code: 500, message: "Unknown Error" });
      }
    });
};
/*
 * This function takes the code token from query, validates it,
 * and matches it with project data.
 * This function will be used in step 3 while exchanging access_token for Authorization Code.
 */
var verifyOAuthCode = function (req, res, next) {
  var token = req.query.code;
  User.findByToken(token, "oauth")
    .then(function (data) {
      if (!data.user) {
        return Promise.reject({ code: 403, message: "Invalid code" });
      }
      var project = req.project;
      var decoded = data.decoded;
      if (
        decoded.projectID != project.projectID ||
        decoded.projectSecret != project.projectSecret ||
        decoded.scope != project.scope
      ) {
        return res
          .status(400)
          .send({
            code: 403,
            message: "The code does not belong to the project",
          });
      }
      req.user = data.user;
      req.decoded = decoded;
      req.token = token;
      next();
    })
    .catch(function (e) {
      if (e && e.code) {
        res.status(e.code).send(e);
      } else {
        console.log(e);
        res.status(500).send({ code: 500, message: "Unknown Error" });
      }
    });
};
/*
 * This function takes the access_token token from query, validates it,
 * and find the user to which it belongs.
 * This function will be used in step 3 while getting user info from access_token.
 */
var verifyAccessToken = function (req, res, next) {
  var token = req.query.access_token;
  User.findByToken(token, "access_token")
    .then(function (data) {
      if (!data.user) {
        return Promise.reject({ code: 403, message: "Invalid Access Token" });
      }
      req.user = data.user;
      req.decoded = data.decoded;
      req.token = token;
      next();
    })
    .catch(function (e) {
      if (e.code) {
        res.status(e.code).send(e);
      } else {
        console.log(e);
        res.status(500).send({ code: 500, message: "Unknown Error" });
      }
    });
};
module.exports = { verifyAccessToken, verifyAuthToken, verifyOAuthCode };

Note: John has used Node.js to create the Authentication Server. But you can create it in any backend language following the above steps.

Kevin and Alex create an account on the CollegeERP app.

Kevin registers the CollegeMeet app on CollegeERP

John needs to provide a set of APIs for project/application creation. These applications will register/authenticate users(like Alex) using John’s CollegeERP app. Using this Kevin can register CollegeMeet to use CollegeERP OAuth.

  1. The first step towards this, is defining a Project Model. John has created a simple project model that contains the necessary information required.
    Name — Name of the project entered my client
    Project ID — Unique Project ID
     — — This is the code generated id. Make it unique.

    Project Secret — Unique encrypted secret
     — — This is also code generated. It is a secret key and should not be exposed

    Scope — The scope limits the application’s access to the user’s account
     — — He has added 4 scopes, you can add as many as you want, depending on your user model

    Redirect URLs: The URL to which our provider will redirect with code once the login is successful
     — — He has defined the “redirectURLs” field as an array. It provides the client, flexibility to add multiple URLs. For e.g. dev, production, etc.
  2. Next create following routes for the project entity.
    a. POST /project — create/register a new project/application to CollegeERP
    b. GET /project — get the list of user’s projects/applications
    c. POST /project/redirectUrl — add a new redirectUrl to a project
  3. The crypto module generates the Project Secret hash.
  4. Now you can add Project Forms to your UI and integrate the API
/*
 * server/models/project.js
 * Project Model File
 */
const mongoose = require("mongoose");
var Schema = mongoose.Schema;

var ProjectSchema = new Schema({
  projectID: {
    type: String,
    required: true,
    unique: true,
  },
  projectSecret: {
    type: String,
    required: true,
    unique: true,
  },
  name: {
    type: String,
    required: true,
    unique: true,
  },
  redirectURLs: [
    {
      type: String,
      required: true,
    },
  ],
  scope: {
    type: String,
    enum: ["default", "email", "phone", "full"],
    default: "default",
  },
  createdBy: {
    type: Schema.ObjectId,
    required: true,
  },
  createdAt: { type: Date, default: Date.now },
});

module.exports = mongoose.model("Project", ProjectSchema);
/*
 * server/routes/ProjectRoutes.js
 * Project Routes File
 */
const express = require("express");
const crypto = require("crypto");
const R = require("ramda");

const Project = require("../models/project");
const { verifyAuthToken } = require("../middlewares/authenticate");

const router = express.Router();
router
  .route("/")
  .post(verifyAuthToken, async function (req, res) {
    var data = R.pick(["name", "redirectURLs", "scope"], req.body);
    data.projectID = data.name.replace(/\s/g, "") + ".myapp.in";
    data.createdBy = req.user._id;
    const hash = crypto
      .createHmac("sha256", process.env.SECRET)
      .update(data.projectID)
      .digest("hex");
    data.projectSecret = hash;
    var project = new Project(data);
    try {
      var project = await project.save();
      res.send(
        R.pick(
          ["name", "projectID", "projectSecret", "redirectURLs", "scope"],
          project
        )
      );
    } catch (e) {
      console.log(e);
      res.status(406).send({ code: 406, message: "Retry with different name" });
    }
  })
  .get(verifyAuthToken, async function (req, res) {
    var project = await Project.find({ createdBy: req.user._id });
    res.send(project);
  })
  .delete(verifyAuthToken, async function (req, res) {
    try {
      var project = await Project.findOne({
        _id: req.body._id,
        createdBy: req.user._id,
      });
      if (project) {
        await project.remove();
        res.send({ message: "Deleted Successfully" });
      } else {
        res
          .status(403)
          .send({
            code: 403,
            message: "You are not authorised to delete this resource",
          });
      }
    } catch (e) {
      res.status(500).send({ code: 500, message: "Unknown Error" });
    }
  });
router.route("/redirectUrl").post(verifyAuthToken, async function (req, res) {
  var projectID = req.body.projectID;
  var redirectURL = req.body.redirectURL;
  try {
    var project = await Project.findOne({ projectID });
    if (project) {
      if (project.createdBy.toHexString() != req.user._id.toHexString()) {
        return res
          .status(403)
          .send({
            code: 403,
            message: "You are not authorized to modify this project",
          });
      }
      project.redirectURLs.push(redirectURL);
      await project.save();
      res.send(
        R.pick(
          ["name", "projectID", "projectSecret", "redirectURLs", "scope"],
          project
        )
      );
    } else {
      res.status(400).send({ message: "Invalid Project Id" });
    }
  } catch (e) {
    console.log(e);
    res.status(500).send({ code: 500, message: "Unknown Error" });
  }
});
module.exports = router;

So now Kevin can sign into the CollegeERP app. He can create a new project, view old ones, and add Redirect URLs to old ones. Kevin creates a new project CollegeMeet and stores the credentials.

The next is the most crucial step. Alex wants to sign into CollegeMeet using his CollegeERP account. Kevin wants to access Alex’s data stored on CollegeERP.

Alex logs into CollegeMeet by clicking on Sign-in with CollegeERP:

To provide this functionality John had to perform the following operations.

  1. Provide a Login URL: John had to provide Kevin a Login URL to which he can redirect to authenticate his users. He defined the following template for the login URL.
    https://college_erp.com/login?projectID=<projectID>&scope=<scope>&redirectURL=<redirectURL>
  2. Verify Kevin’s application: John needs to verify the details of the CollegeMeet app. He created an API (GET /oauth/verifyProject) for this. This API takes projectID, scope, and redirectURL to test whether this project exists.
  3. Ask Alex for consent: John needs to ask Alex for his consent. Giving his consent Alex allows CollegeMeet to access his information on CollegeERP.
  4. Generate Authorization Code: John needs to generate an authorization code. This code is exclusive for CollegeMeet. It is a one-time use code. He designed an API (GET /oauth/code). This API takes the projectID, scope, and redirectURL as parameters. It also takes the x-auth token as a header. It then generates a JWT Authorization Code.
  5. Retrieve Access Token: Now Kevin’s app has to exchange its authorization code for access_token. For this John provides an API (GET /oauth/token). This API takes the projectID, scope, redirectURL, projectSecret, and code. The API returns an access_token. Kevin’s app must store this token for future use.

    Note: Some providers provide access_token in the 4th step. In that case we can skip the 5th step.
  6. Get user info with the access token: The last step is for what we have come so far in the article. After retrieving the access_token, Kevin wants to access Alex’s data. For this John designed an API (GET /oauth/userinfo). This API takes access_token as a parameter and returns user information. 

    Note: The access_token should be temporary. You should provide an extra refresh token to refresh the access_token after expiry. For the simplicity of this article, I have used non-expiring access tokens.
/*
 * server/routes/OAuthRoutes.js
 * OAUth Routes File
 */
const express = require("express");
const R = require("ramda");

const projectMiddleware = require("../middlewares/projectMiddleware");
const {
  verifyAccessToken,
  verifyAuthToken,
  verifyOAuthCode,
} = require("../middlewares/authenticate");

const router = express.Router();

const scopeMapping = {
  full: ["_id", "name", "email", "phone"],
  default: ["_id", "name"],
  email: ["_id", "name", "email"],
  phone: ["_id", "name", "phone"],
};

router
  .route("/verifyproject")
  .get(projectMiddleware, async function (req, res) {
    res.send(R.pick(["name", "scope"], req.project));
  });
router
  .route("/code")
  .get(projectMiddleware, verifyAuthToken, async function (req, res) {
    try {
      var code = await req.user.generateOAuthCode(req.project);
      redirectURL = `${req.query.redirectURL}?code=${code}`;
      return res.send({ redirectURL });
    } catch (e) {
      console.log(e);
      res.status(500).send({ message: "Unknown Error", code: 500 });
    }
  });
router
  .route("/token")
  .get(projectMiddleware, verifyOAuthCode, async function (req, res) {
    if (req.project.projectSecret != req.query.projectSecret) {
      return res
        .status(400)
        .send({ code: 400, message: "Mismatch ProjectID and Secret" });
    }
    user = req.user;

    user
      .generateAccessToken(req.decoded.scope)
      .then((token) => {
        return user.removeToken(req.token).then((e) => {
          return token;
        });
      })
      .then((token) => {
        res.send({ access_token: token });
      })
      .catch((e) => {
        res
          .status(400)
          .send({ message: "Error while generating access token" });
      });
  });

router.route("/userinfo").get(verifyAccessToken, async function (req, res) {
  token = req.decoded;
  user = req.user;
  res.send(R.pick(scopeMapping[token.scope], user));
});

module.exports = router;
/*
 * server/middlewares/projectMiddleware.js
 * Project Middleware File
 */
const Project = require("../models/project");

var projectMiddle = function (req, res, next) {
  var projectID = req.query.projectID;
  var redirectURL = req.query.redirectURL;
  var scope = req.query.scope;
  Project.findOne({ projectID })
    .then(function (project) {
      if (!project) {
        return Promise.reject({
          code: 404,
          message: "Project ID does not exist",
        });
      }
      if (!project.redirectURLs.includes(redirectURL)) {
        return Promise.reject({ code: 400, message: "Redirect URL mismatch" });
      }
      if (project.scope != scope) {
        return Promise.reject({ code: 400, message: "Invalid Scope" });
      }
      req.project = project;
      next();
    })
    .catch(function (e) {
      if (e.code) {
        res.status(e.code).send(e);
      } else {
        res.status(500).send({ message: "Unknown Error" });
      }
    });
};

module.exports = projectMiddle;

Alex comes to the CollegeMeet website, clicks on Sign in with the CollegeERP button. Then they briefly go to the CollegeERP website and land back into CollegeMeet site but authenticated.

Voila!!! There it is… John has created an OAuth Provider for his CollegeERP App. Kevin has created his CollegeMeet App using this OAuth Provider. Alex can now login to CollegeMeet App using his CollegeERP account.

This app was only created for this article. The production version must have more levels of security and validations. This describes the basic structure and flow of how OAuth functions behind the scene. I tried to follow the google OAuth 2 flow as closely as possible but there is always a scope of improvement.

The frontend part of this application is beyond the scope of this article. Comment, if you are interested in knowing how I implemented the frontend. I will write a separate article explaining the frontend of this application.