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

Step-by-Step: How to Implement OAuth2 Server in ExpressJS

Romeo Bellon

By Romeo Bellon

Last update on Apr 17, 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.js
3├─ models/
4│ ├─ oauth.js
5│ ├─ index.js
6│ └─ // other models
7├─ services/
8│ ├─ oauth2.service.js
9│ ├─ index.js
10│ └─ // other services
11├─ routes/
12 ├─ oauth-flow.js
13 ├─ index.js
14 └─ // 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.js
2
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 needed
44 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 needed
57 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._id
96 };
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.id
145 });
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.id
155 });
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 Zapier
168 access_token: token.accessToken,
169 refresh_token: token.refreshToken
170 };
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 expires
199 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 getRefreshToken
214};

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.js
2
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 specification
8});
9
10// IMPORTANT: this the first route to be called in the process.
11// node-oauth2-server requires us to define a function called
12// `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 search
16// 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 do
18// when adding a new app to GitHub and it asks you what organization or
19// privileges do you want to grant the app. The bonus of this article is
20// that very screen).
21
22// The library says that if you don't need to authenticate the user, you can
23// return a falsy value. This didn't work for me so I'm not recommending it
24// here.
25
26const authorize = async (req, res) => {
27 const request = new Request(req);
28 const response = new Response(res);
29 return server
30 .authorize(request, response, {
31 authenticateHandler: {
32 handle: async () => {
33 // Present in Flow 1 and Flow 2 ('client_id' is a required for /oauth/authorize
34 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 client
42 if (!client.userId && !userId) return {}; // falsy value
43 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 server
65 .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 server
79 .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.js
2
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.js
2
3import express from "express";
4import oAuthFlowRoutes from "routes/oauth-flow";
5// ...
6
7// initialize express server
8const 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.