Step-by-Step_ How to Implement OAuth2 Server in ExpressJS.png

Step-by-Step: How to Implement OAuth2 Server in ExpressJS (NodeJS)

Romeo Bellon

By Romeo Bellon

Last update on Jun 26, 2023 · 8 minutes


In the following article, I'll outline how to implement OAuth2 Server in your ExpressJS application. You want to add this functionality to your application if you intend to securely give your users access to your API or integrate with third-party services like Zapier.

My goal is to allow you to implement OAuth2 in the shortest time possible while understanding it. I just finished implementing OAuth2 Server for Isla (which is now in public beta, so check it out!) and followed precisely these steps.

Frameworks and Libraries

  • ExpressJS
  • Mongoose (database models for MongoDB, although it will be clear to you how to plug in your own ODMs or ORMs)
  • node-oauth2-server (will handle the technicalities for us)
  • Bonus: a frontend framework to handle the customer-facing authorization screen when authenticating with Zapier, for example.

Key Concepts

  • Client: the application wishing to have access to your resources; it could be Zapier or the application through which your customers connect.
  • User: the person wishing to use your resources on the Client.
  • Authorization: the process of determining whether something has access to protected resources.
  • Authentication: the process of determining a person's identity.
  • OAuth2.0: A protocol outlining how authorization should happen. It is NOT an authentication library. You will need to provide that yourself.
  • Grant type: how your app gets the access token (i.e., Authorization Code, Client Credentials, and others).

Each of the grants provides a token that enables the user to access resources like the following diagram shows:

  1. The token is passed up in the authorization header;
  2. OAuth2 Server validates the token;
  3. Protected Resources are sent back down.
OAuth2 Protected ResourcesOAuth2 Protected Resources

In this article, we'll focus only on the authorization_code and refresh_token grants, although node-oauth2-server supports client_credentials and password grants, as well as extension grants, with scopes.

Endpoints Overview

OAuth2 Authorization CodeOAuth2 Authorization Code

1. Authorization

  • Client contacts the Server and requests access;
  • Client provides a client_id (unique string identifier);
  • Client provides a redirect URI to send the user after the code is delivered;
  • Client may provide user data for authentication purposes;
  • Server validates the information and sends back an authorization code.

Endpoint: GET /oauth/authorize

Query Parameters:

  • client_id: string
  • response_type: "code"
  • redirect_uri: string
  • state: string, random and non-guessable
  • scope: "default"

Functions called:

  • getClient
  • saveAuthorizationCode

2. Token

  • Client uses the received authorization code to request a token;
  • Client sends the authorization_code, the client_id, and the client_secret (if applicable);
  • Server validates the request and sends a token.

Endpoint: POST /oauth/token

Body Parameters (x-www-form-urlencoded):

  • client_id: string, same as in the previous step
  • client_secret: string
  • grant_type: "authorization_code"
  • code: "authorizationCode" returned from the server after the 1. Authorization
  • redirect_uri: string

Functions called:

  • revokeAuthorizationCode
  • saveToken

3. Authentication

  • Client uses the received token to gain access to Server's protected resources.

Endpoint: GET /oauth/me (or any other protected resource)

Headers:

  • Authorization: Bearer <access token from the previous step>

Functions called:

  • getAccessToken

4. Refresh Token

  • Client requests a new access token using the refresh token previously received in Step 2
  • Server checks the refresh token, generates a new access token, and sends it back to the Client

Endpoint: POST /oauth/token

Note: this endpoint is often automatically called by services like Zapier upon receiving an HTTP Error Code 401 when accessing a protected resource.

Body Parameters (x-www-form-urlencoded):

  • client_id: string, same as in the previous step
  • client_secret: string
  • grant_type: "refresh_token"
  • refresh_token: "refreshToken" returned from the server after the 2. Authorization

Functions called:

  • getClient
  • getRefreshToken
  • saveToken

Implementation

Important #1: Before implementing the following, you want to have a Client document in your OAuthClients collection. If you think about the process of integrating with services like GitHub, the first thing you do is create an OAuth2 App. Same here. You want your users to create an OAuth2 App (i.e., Client) in your system with a userId referencing the user attached to it. This means you will have to create a section in your App to allow users to create, edit, and list OAuth Clients. Upon the creation of a Client, you also want to generate a clientId and a clientSecret or decide to have multiple per Client.

Important #2: In the case of services like Zapier, you want to have a pre-existing Client document in your OAuthClients collection dedicated exclusively to Zapier. In fact, when you integrate with Zapier, they ask you for a pre-defined clientId and clientSecret. In this case, userId is undefined, and that's fine; we'll handle that scenario in the authenticateHandler function down below.

text|1my-app/2├─ app.js3├─ models/4│  ├─ oauth.js5│  ├─ index.js6│  └─ // other models7├─ services/8│  ├─ oauth2.service.js9│  ├─ index.js10│  └─ // other services11├─ routes/12   ├─ oauth-flow.js13   ├─ index.js14   └─ // other routes

Then, node-oauth2-server wants us to redefine certain functions that will interact with our database along the way. In particular:

javascript|1// models/oauth.js2
3import {v4 as uuid} from "uuid";4import mongoose from "mongoose";5
6const {Schema} = mongoose;7
8/**9 * Schema definitions.10 */11mongoose.model(12  "OAuthClients",13  new Schema({14    _id: {type: String, auto: true},15    userId: {type: String},16    clientId: {type: String},17    clientSecret: {type: String},18    callbackUrl: {type: Date},19    grants: {type: [String], required: true, enum: ["authorization_code", "refresh_token"]}20  }),21  "oauth-authorization-codes"22);23mongoose.model(24  "OAuthAuthorizationCodes",25  new Schema({26    _id: {type: String, auto: true},27    authorizationCode: {type: String},28    expiresAt: {type: Date},29    redirectUri: {type: String},30    scope: {type: String},31    clientId: {type: String},32    userId: {type: String}33  }),34  "oauth-authorization-codes"35);36
37mongoose.model(38  "OAuthAccessTokens",39  new Schema({40    _id: {type: String},41    accessToken: {type: String},42    accessTokenExpiresAt: {type: Date},43    scope: {type: String}, // not sure if this is needed44    clientId: {type: String},45    userId: {type: String}46  }),47  "oauth-access-tokens"48);49
50mongoose.model(51  "OAuthRefreshTokens",52  new Schema({53    _id: {type: String},54    refreshToken: {type: String},55    refreshTokenExpiresAt: {type: Date},56    scope: {type: String}, // not sure if this is needed57    clientId: {type: String},58    userId: {type: String}59  }),60  "oauth-refresh-tokens"61);62
63const OAuthClientsModel = mongoose.model("OAuthClients");64const OAuthAuthorizationCodesModel = mongoose.model("OAuthAuthorizationCodes");65const OAuthAccessTokensModel = mongoose.model("OAuthAccessTokens");66const OAuthRefreshTokensModel = mongoose.model("OAuthRefreshTokens");67
68/**69 * Get an OAuth2 Client.70 *71 * Called in 1. Authorization and 4. Refresh Token.72 * 'clientSecret' is defined when refreshing the token.73 */74async function getClient(clientId, clientSecret) {75  const client = await OAuthClientsModel.findOne({clientId, ...(clientSecret && {clientSecret})}).lean();76  if (!client) throw new Error("Client not found");77
78  return {79    id: client.clientId,80    grants: client.grants,81    redirectUris: [client.callbackUrl]82  };83}84
85/**86 * Save authorization code.87 */88async function saveAuthorizationCode(code, client, user) {89  const authorizationCode = {90    authorizationCode: code.authorizationCode,91    expiresAt: code.expiresAt,92    redirectUri: code.redirectUri,93    scope: code.scope,94    clientId: client.id,95    userId: user._id96  };97  await OAuthAuthorizationCodesModel.create({_id: uuid(), ...authorizationCode});98  return authorizationCode;99}100
101/**102 * Get authorization code.103 */104async function getAuthorizationCode(authorizationCode) {105  const code = await OAuthAuthorizationCodesModel.findOne({authorizationCode}).lean();106  if (!code) throw new Error("Authorization code not found");107
108  return {109    code: code.authorizationCode,110    expiresAt: code.expiresAt,111    redirectUri: code.redirectUri,112    scope: code.scope,113    client: {id: code.clientId},114    user: {id: code.userId}115  };116}117
118/**119 * Revoke authorization code.120 */121async function revokeAuthorizationCode({code}) {122  const res = await OAuthAuthorizationCodesModel.deleteOne({authorizationCode: code});123  return res.deletedCount === 1;124}125
126/**127 * Revoke a refresh token.128 */129async function revokeToken({refreshToken}) {130  const res = await OAuthAccessTokensModel.deleteOne({refreshToken});131  return res.deletedCount === 1;132}133
134/**135 * Save token.136 */137async function saveToken(token, client, user) {138  await OAuthAccessTokensModel.create({139    accessToken: token.accessToken,140    accessTokenExpiresAt: token.accessTokenExpiresAt,141    scope: token.scope,142    _id: uuid(),143    clientId: client.id,144    userId: user.id145  });146
147  if (token.refreshToken) {148    await OAuthRefreshTokensModel.create({149      refreshToken: token.refreshToken,150      refreshTokenExpiresAt: token.refreshTokenExpiresAt,151      scope: token.scope,152      _id: uuid(),153      clientId: client.id,154      userId: user.id155    });156  }157
158  return {159    accessToken: token.accessToken,160    accessTokenExpiresAt: token.accessTokenExpiresAt,161    refreshToken: token.refreshToken,162    refreshTokenExpiresAt: token.refreshTokenExpiresAt,163    scope: token.scope,164    client: {id: client.id},165    user: {id: user.id},166
167    // other formats, i.e. for Zapier168    access_token: token.accessToken,169    refresh_token: token.refreshToken170  };171}172
173/**174 * Get access token.175 */176async function getAccessToken(accessToken) {177  const token = await OAuthAccessTokensModel.findOne({accessToken}).lean();178  if (!token) throw new Error("Access token not found");179
180  return {181    accessToken: token.accessToken,182    accessTokenExpiresAt: token.accessTokenExpiresAt,183    scope: token.scope,184    client: {id: token.clientId},185    user: {id: token.userId}186  };187}188
189/**190 * Get refresh token.191 */192async function getRefreshToken(refreshToken) {193  const token = await OAuthRefreshTokensModel.findOne({refreshToken}).lean();194  if (!token) throw new Error("Refresh token not found");195
196  return {197    refreshToken: token.refreshToken,198    // refreshTokenExpiresAt: token.refreshTokenExpiresAt, // never expires199    scope: token.scope,200    client: {id: token.clientId},201    user: {id: token.userId}202  };203}204
205export default {206  saveToken,207  saveAuthorizationCode,208  revokeAuthorizationCode,209  revokeToken,210  getAuthorizationCode,211  getAccessToken,212  getClient,213  getRefreshToken214};

In all our projects at Merlino Software Agency we use the Clean Architecture, which makes it easy not to mess up with dependencies across the project and helps keep everything organized.

For the sake of this article, however, I'm going to group all OAuth2 route declarations into a single OAuth2 service.

javascript|1// services/oauth2.service.js2
3import OAuth2Server, {Request, Response} from "oauth2-server";4import {OAuth, OAuthClient, Users} from "models";5
6const server = new OAuth2Server({7  model: OAuth // See https://github.com/oauthjs/node-oauth2-server for specification8});9
10// IMPORTANT: this the first route to be called in the process. 11// node-oauth2-server requires us to define a function called12// `authenticateHandler` that authenticate the user initiating the flow.13
14// This means that:15// 1. A User is authenticating through a Client, hence you need to search16// for a valid Client in the DB using `client_id` provided as parameter;17// 2. A User is authenticating via the authorization screen (same as you do18// when adding a new app to GitHub and it asks you what organization or19// privileges do you want to grant the app. The bonus of this article is20// that very screen).21
22// The library says that if you don't need to authenticate the user, you can23// return a falsy value. This didn't work for me so I'm not recommending it24// here. 25
26const authorize = async (req, res) => {27  const request = new Request(req);28  const response = new Response(res);29  return server30    .authorize(request, response, {31      authenticateHandler: {32        handle: async () => {33          // Present in Flow 1 and Flow 2 ('client_id' is a required for /oauth/authorize34          const {client_id} = req.query || {};35          if (!client_id) throw new Error("Client ID not found");36          const client = await OAuthClient.findOne({clientId: client_id});37          if (!client) throw new Error("Client not found");38          // Only present in Flow 2 (authentication screen)39          const {userId} = req.auth || {};40
41          // At this point, if there's no 'userId' attached to the client or the request doesn't originate from an authentication screen, then do not bind this authorization code to any user, just the client42          if (!client.userId && !userId) return {}; // falsy value43          const user = await usersDb.findOne({44            ...(client.userId && {_id: client.userId}),45            ...(userId && {clerkId: userId})46          });47          if (!user) throw new Error("User not found");48          return {_id: user._id};49        }50      }51    })52    .then((result) => {53      res.json(result);54    })55    .catch((err) => {56      console.log("err", err);57      res.status(err.code || 500).json(err instanceof Error ? {error: err.message} : err);58    });59};60
61const token = (req, res) => {62  const request = new Request(req);63  const response = new Response(res);64  return server65    .token(request, response, {alwaysIssueNewRefreshToken: false})66    .then((result) => {67      res.json(result);68    })69    .catch((err) => {70      console.log("err", err);71      res.status(err.code || 500).json(err instanceof Error ? {error: err.message} : err);72    });73};74
75const authenticate = (req, res, next) => {76  const request = new Request(req);77  const response = new Response(res);78  return server79    .authenticate(request, response)80    .then((data) => {81      req.auth = {userId: data?.user?.id, sessionType: "oauth2"};82      next();83    })84    .catch((err) => {85      console.log("err", err);86      res.status(err.code || 500).json(err instanceof Error ? {error: err.message} : err);87    });88};89
90const test = async (req, res) => {91  const {userId} = req.auth || {};92  if (!userId) throw new Error("User not found");93  const user = await usersDb.findOne({_id: userId});94  if (!user) throw new Error("User not found");95  res.json({_id: user._id, username: user.username});96};97
98export default {server, authorize, token, authenticate, test};

Now, let's map these functions to their respective routes.

javascript|1// routes/oauth-flow.js2
3import express from "express";4import {authorize, token, authenticate, test} from "services/oauth2.service";5
6const router = express.Router();7
8router.get("/authorize", authorize);9router.post("/token", token);10router.get("/authenticate", authenticate, test);11
12export default router;

Finally, let's reference the newly created routes in app.js.

javascript|1// app.js2
3import express from "express";4import oAuthFlowRoutes from "routes/oauth-flow";5// ...6
7// initialize express server8const app = express();9app.use("/oauth", oAuthFlowRoutes);10// ...

That's it!

I suggest you use a tool such as Postman to test this flow. All the best with the implementation, and please do not hesitate to let me know any feedback in the comments.

What do you think about this article?

Whether it was helpful or not, we would love to hear your thoughts! Please leave a comment below.