Step-by-Step: How to Implement OAuth2 Server in ExpressJS (NodeJS)
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:
- The token is passed up in the authorization header;
- OAuth2 Server validates the token;
- Protected Resources are sent back down.
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
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.