propro-utils 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,68 @@
1
+ const {
2
+ validateEnvironmentVariables,
3
+ } = require("./server/middleware/validateEnv");
4
+ let _serverAuth, _clientAuth;
5
+
6
+ /**
7
+ * Middleware for handling both server-side and client-side authentication.
8
+ *
9
+ * @param {Object} options - Configuration options for the middleware.
10
+ * @param {boolean} [options.useServerAuth=true] - A boolean flag to enable server-side authentication.
11
+ * @param {Object} [options.serverOptions={}] - Configuration options for server-side authentication.
12
+ * Example:
13
+ * {
14
+ * jwtSecret: 'HubHubJWTSecret', // Secret key for JWT token verification
15
+ * tokenExpiry: 3600, // Token expiry time in seconds
16
+ * authUrl: 'https://propro.so/auth', // URL of the authentication server
17
+ * clientId: 'propro', // Client ID
18
+ * clientSecret: 'propro', // Client secret
19
+ * clientUrl: 'https://propro.so', // URL of the authentication client
20
+ * redirectUri: 'https://propro.so/callback', // Redirect URI
21
+ * validateUser: async (userId) => { }, // Function to validate user
22
+ * onAuthFailRedirect: '/login', // URL to redirect on authentication failure
23
+ * additionalChecks: async (req) => { }, // Additional custom checks for requests
24
+ * }
25
+ * @param {boolean} [options.useClientAuth=false] - A boolean flag to enable client-side authentication.
26
+ * @param {Object} [options.clientOptions={}] - Configuration options for client-side authentication.
27
+ *
28
+ * @returns {Function} An Express middleware function.
29
+ *
30
+ * Example usage:
31
+ * app.use(proproAuthMiddleware({
32
+ * useServerAuth: true,
33
+ * serverOptions: {
34
+ * jwtSecret: 'HubHubJWTSecret',
35
+ * tokenExpiry: 3600,
36
+ * validateUser: async (userId) => { },
37
+ * onAuthFailRedirect: '/login',
38
+ * additionalChecks: async (req) => { },
39
+ * },
40
+ * useClientAuth: false,
41
+ * }));
42
+ */
43
+ module.exports = function proproAuthMiddleware(options = {}) {
44
+ validateEnvironmentVariables([
45
+ "AUTH_URL",
46
+ "CLIENT_ID",
47
+ "CLIENT_SECRET",
48
+ "CLIENT_URL",
49
+ "REDIRECT_URI",
50
+ ]);
51
+ return (req, res, next) => {
52
+ try {
53
+ // Lazy loading and initializing server and client authentication modules with options
54
+ if (options.useServerAuth) {
55
+ _serverAuth = _serverAuth || require("./server")(options.serverOptions);
56
+ _serverAuth(req, res, next);
57
+ } else if (options.useClientAuth) {
58
+ _clientAuth = _clientAuth || require("./client")(options.clientOptions);
59
+ _clientAuth(req, res, next);
60
+ } else {
61
+ next();
62
+ }
63
+ } catch (error) {
64
+ console.error("Error in authentication middleware:", error);
65
+ next(error);
66
+ }
67
+ };
68
+ };
@@ -0,0 +1,108 @@
1
+ require("dotenv").config();
2
+ const {
3
+ verifyJWT,
4
+ exchangeToken,
5
+ VerifyAccount,
6
+ } = require("./middleware/verifyToken");
7
+ // Main middleware function
8
+ function proproAuthMiddleware(options = {}) {
9
+ const {
10
+ secret = "RESTFULAPIs",
11
+ authUrl = process.env.AUTH_URL,
12
+ clientId = process.env.CLIENT_ID,
13
+ clientSecret = process.env.CLIENT_SECRET,
14
+ clientUrl = process.env.CLIENT_URL,
15
+ redirectUri = process.env.REDIRECT_URI,
16
+ appName = process.env.APP_NAME,
17
+ } = options;
18
+
19
+ return async (req, res, next) => {
20
+ try {
21
+ // Try to get the token from the Authorization header
22
+ let token;
23
+ if (req.headers.authorization?.startsWith("Bearer ")) {
24
+ token = req.headers.authorization.split(" ")[1];
25
+ } else {
26
+ // If not present in Authorization header, try to get it from cookies
27
+ token = req.cookies['x-access-token'];
28
+ }
29
+
30
+ if (token) {
31
+ const verifiedToken = verifyJWT(token, secret);
32
+ if (verifiedToken) {
33
+ req.account = verifiedToken;
34
+ } else {
35
+ // If token is invalid or expired, try to refresh it
36
+ const refreshToken = req.cookies['x-refresh-token'];
37
+ if (refreshToken) {
38
+ const newTokenData = await refreshToken(authUrl, refreshToken, clientId, clientSecret);
39
+ if (newTokenData) {
40
+ // Set the new tokens in the cookies
41
+ res.cookie("x-refresh-token", newTokenData.tokens.refresh.token, {
42
+ httpOnly: true,
43
+ secure: process.env.NODE_ENV === "production",
44
+ });
45
+
46
+ res.cookie("x-access-token", newTokenData.tokens.access.token, {
47
+ httpOnly: true,
48
+ secure: process.env.NODE_ENV === "production",
49
+ });
50
+
51
+ req.account = verifyJWT(newTokenData.tokens.access.token, secret);
52
+ }
53
+ }
54
+ }
55
+ } else {
56
+ req.account = undefined;
57
+ }
58
+
59
+ if (!["/api/auth", "/api/callback"].includes(req.path)) {
60
+ return next();
61
+ }
62
+
63
+ if (req.path === "/api/auth") {
64
+ const authClientUrl = `${clientUrl}/signin`;
65
+ const redirectUrl = `${authClientUrl}?response_type=code&appName=${appName}&client_id=${clientId}&redirect_uri=${encodeURIComponent(
66
+ redirectUri
67
+ )}`;
68
+ res.status(200).json({ redirectUrl });
69
+ }
70
+
71
+ if (req.path === "/api/callback") {
72
+ const code = req.query.code;
73
+ if (!code) {
74
+ return res.status(400).send("No code received");
75
+ }
76
+
77
+ console.log("code", code);
78
+
79
+ const tokenData = await exchangeToken(
80
+ authUrl,
81
+ code,
82
+ clientId,
83
+ clientSecret,
84
+ redirectUri
85
+ );
86
+
87
+ res.cookie("x-refresh-token", tokenData.tokens.refresh.token, {
88
+ httpOnly: true,
89
+ secure: process.env.NODE_ENV === "production",
90
+ });
91
+
92
+ res.cookie("x-access-token", tokenData.tokens.access.token, {
93
+ httpOnly: true,
94
+ secure: process.env.NODE_ENV === "production",
95
+ });
96
+
97
+ const redirectUrl = `http://${tokenData.redirectUrl}/`;
98
+
99
+ return res.redirect(redirectUrl);
100
+ }
101
+ } catch (error) {
102
+ console.error("Error in proproAuthMiddleware:", error);
103
+ res.status(500).send("Internal Server Error");
104
+ }
105
+ };
106
+ }
107
+
108
+ module.exports = proproAuthMiddleware;
@@ -0,0 +1,3 @@
1
+ module.exports.refreshTokenMiddleware = require("./refreshToken");
2
+ module.exports.verifyTokenMiddleware = require("./verifyToken");
3
+ module.exports.validateEnv = require("./validateEnv");
@@ -0,0 +1,203 @@
1
+ describe('Test Middleware', () => {
2
+ // Middleware correctly verifies JWT token and sets user in request object
3
+ const jwt = require('jsonwebtoken');
4
+ const middleware = require('../middleware');
5
+
6
+ jest.mock('jsonwebtoken');
7
+
8
+ describe('Set user if the jwt is valid', () => {
9
+ it('should set user in request object when JWT token is valid', () => {
10
+ const req = {
11
+ headers: {
12
+ authorization: 'JWT validToken',
13
+ },
14
+ };
15
+ const res = {};
16
+ const next = jest.fn();
17
+ const decode = { username: 'testUser' };
18
+ jwt.verify.mockImplementation((token, secret, callback) => {
19
+ callback(null, decode);
20
+ });
21
+
22
+ middleware(req, res, next);
23
+
24
+ expect(req.user).toEqual(decode);
25
+ expect(next).toHaveBeenCalled();
26
+ });
27
+ });
28
+
29
+ // GET request to '/test-refresh-token' returns JSON with message and access token
30
+ const request = require('supertest');
31
+ const app = require('..');
32
+
33
+ describe('return a message and an access token', () => {
34
+ it('should return JSON with message and access token', async () => {
35
+ const response = await request(app)
36
+ .get('/test-refresh-token')
37
+ .set('Authorization', 'JWT validToken');
38
+
39
+ expect(response.status).toBe(200);
40
+ expect(response.body.message).toBe('Access granted with a new token');
41
+ expect(response.body.accessToken).toBeDefined();
42
+ });
43
+ });
44
+
45
+ // GET request to '/auth' redirects to authorization server URL with correct parameters
46
+ const request = require('supertest');
47
+ const app = require('..');
48
+
49
+ describe('redirect with correct parameter', () => {
50
+ it('should redirect to authorization server URL with correct parameters', async () => {
51
+ process.env.AUTH_URL = 'http://auth-server.com';
52
+ process.env.CLIENT_ID = 'testClientId';
53
+ process.env.REDIRECT_URI = 'http://client.com/callback';
54
+
55
+ const response = await request(app).get('/auth');
56
+
57
+ expect(response.status).toBe(302);
58
+ expect(response.header.location).toBe(
59
+ 'http://auth-server.com/oauth/authorize?response_type=code&client_id=testClientId&redirect_uri=http%3A%2F%2Fclient.com%2Fcallback',
60
+ );
61
+ });
62
+ });
63
+
64
+ // Authorization header is missing or invalid
65
+ const middleware = require('../middleware');
66
+
67
+ describe('Set user to undefined on missing header', () => {
68
+ it('should set user to undefined when authorization header is missing or invalid', () => {
69
+ const req = {
70
+ headers: {},
71
+ };
72
+ const res = {};
73
+ const next = jest.fn();
74
+
75
+ middleware(req, res, next);
76
+
77
+ expect(req.user).toBeUndefined();
78
+ expect(next).toHaveBeenCalled();
79
+ });
80
+ });
81
+
82
+ // No authorization code received in '/callback' endpoint
83
+ const request = require('supertest');
84
+ const app = require('..');
85
+
86
+ describe('Error when excchange code is received', () => {
87
+ it('should return 400 when no authorization code is received', async () => {
88
+ const response = await request(app).get('/callback');
89
+
90
+ expect(response.status).toBe(400);
91
+ expect(response.text).toBe('No code received');
92
+ });
93
+ });
94
+
95
+ // Error occurs during token exchange in '/callback' endpoint
96
+ const request = require('supertest');
97
+ const axios = require('axios');
98
+ const app = require('..');
99
+
100
+ jest.mock('axios');
101
+
102
+ describe('Error during token exchange', () => {
103
+ it('should return 500 when error occurs during token exchange', async () => {
104
+ const req = {
105
+ query: {
106
+ code: 'validCode',
107
+ },
108
+ };
109
+ const res = {};
110
+ axios.post.mockRejectedValue(new Error('Test error'));
111
+
112
+ const response = await request(app).get('/callback').query(req.query);
113
+
114
+ expect(response.status).toBe(500);
115
+ expect(response.text).toBe('Internal Server Error');
116
+ });
117
+ });
118
+
119
+ // GET request to '/callback' exchanges authorization code for access token and sets cookie
120
+ it('should exchange authorization code for access token and set cookie', async () => {
121
+ const req = {
122
+ query: {
123
+ code: 'authorizationCode',
124
+ },
125
+ };
126
+ const res = {
127
+ status: jest.fn().mockReturnThis(),
128
+ send: jest.fn(),
129
+ redirect: jest.fn(),
130
+ cookie: jest.fn(),
131
+ };
132
+ const tokenResponse = {
133
+ data: {
134
+ access_token: 'accessToken',
135
+ refresh_token: 'refreshToken',
136
+ },
137
+ };
138
+ axios.post.mockResolvedValue(tokenResponse);
139
+
140
+ await callback(req, res);
141
+
142
+ expect(axios.post).toHaveBeenCalledWith(
143
+ `${process.env.AUTH_URL}/oauth/token`,
144
+ qs.stringify({
145
+ grant_type: 'authorization_code',
146
+ code: req.query.code,
147
+ redirect_uri: process.env.REDIRECT_URI,
148
+ client_id: process.env.CLIENT_ID,
149
+ client_secret: process.env.CLIENT_SECRET,
150
+ }),
151
+ {
152
+ headers: {
153
+ 'Content-Type': 'application/json',
154
+ },
155
+ },
156
+ );
157
+ expect(res.cookie).toHaveBeenCalledWith(
158
+ 'token',
159
+ tokenResponse.data.access_token,
160
+ {
161
+ httpOnly: true,
162
+ secure: process.env.NODE_ENV === 'production',
163
+ },
164
+ );
165
+ expect(res.redirect).toHaveBeenCalledWith(
166
+ `${process.env.CLIENT_URL}/?access_token=${tokenResponse.data.access_token}&refresh_token=${tokenResponse.data.refresh_token}`,
167
+ );
168
+ });
169
+
170
+ // Unauthorized user receives 401 status code
171
+ it('should return 401 status code for unauthorized user', () => {
172
+ const req = {};
173
+ const res = {
174
+ status: jest.fn().mockReturnThis(),
175
+ send: jest.fn(),
176
+ };
177
+
178
+ middleware(req, res);
179
+
180
+ expect(res.status).toHaveBeenCalledWith(401);
181
+ expect(res.send).toHaveBeenCalledWith('Unauthorized');
182
+ });
183
+
184
+ // Invalid redirect URI returns error in '/auth' endpoint
185
+ it('should return error for invalid redirect URI', () => {
186
+ const req = {};
187
+ const res = {
188
+ status: jest.fn().mockReturnThis(),
189
+ send: jest.fn(),
190
+ };
191
+ const authServerUrl = `${process.env.AUTH_URL}/oauth/authorize`;
192
+ const clientId = process.env.CLIENT_ID;
193
+ const redirectUri = process.env.REDIRECT_URI;
194
+
195
+ auth(req, res);
196
+
197
+ expect(res.redirect).toHaveBeenCalledWith(
198
+ `${authServerUrl}?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(
199
+ redirectUri,
200
+ )}`,
201
+ );
202
+ });
203
+ });
@@ -0,0 +1,82 @@
1
+ const axios = require("axios");
2
+ const rateLimit = require("express-rate-limit");
3
+ const refreshTokenCache = new Map();
4
+
5
+ /**
6
+ * Rate limiter middleware for refresh requests.
7
+ *
8
+ * @type {import('express-rate-limit').RateLimit}
9
+ */
10
+ const refreshLimiter = rateLimit({
11
+ windowMs: 15 * 60 * 1000, // 15 minutes
12
+ max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
13
+ message:
14
+ "Too many refresh requests from this IP, please try again after 15 minutes",
15
+ });
16
+
17
+ /**
18
+ * Middleware function to refresh access token using refresh token.
19
+ * @async
20
+ * @function
21
+ * @param {Object} req - Express request object.
22
+ * @param {Object} res - Express response object.
23
+ * @param {Function} next - Express next middleware function.
24
+ * @returns {Promise<void>} - Promise object that represents the completion of the middleware function.
25
+ */
26
+ const refreshTokenMiddleware = async (req, res, next) => {
27
+ // Apply rate limiting
28
+ refreshLimiter(req, res, async () => {
29
+ const refreshToken = req.headers["x-refresh-token"];
30
+
31
+ if (!refreshToken) {
32
+ return res.status(401).json({ error: "No refresh token provided" });
33
+ }
34
+
35
+ if (!isValidRefreshTokenFormat(refreshToken)) {
36
+ return res.status(400).json({ error: "Invalid refresh token format" });
37
+ }
38
+
39
+ if (refreshTokenCache.has(refreshToken)) {
40
+ req.newAccessToken = refreshTokenCache.get(refreshToken);
41
+ return next();
42
+ }
43
+
44
+ try {
45
+ const response = await axios.post(
46
+ `${process.env.AUTH_URL}/oauth/token`,
47
+ URLSearchParams({
48
+ grant_type: "refresh_token",
49
+ refresh_token: refreshToken,
50
+ client_id: process.env.CLIENT_ID,
51
+ client_secret: process.env.CLIENT_SECRET,
52
+ }),
53
+ { headers: { "Content-Type": "application/x-www-form-urlencoded" } }
54
+ );
55
+
56
+ if (response.data && response.data.access_token) {
57
+ refreshTokenCache.set(refreshToken, response.data.access_token);
58
+ req.newAccessToken = response.data.access_token;
59
+ return next();
60
+ } else {
61
+ return res.status(401).json({ error: "Unable to refresh token" });
62
+ }
63
+ } catch (error) {
64
+ const statusCode = error.response?.status || 500;
65
+ const message = error.response?.data?.error || "Error refreshing token";
66
+ return res.status(statusCode).json({ error: message });
67
+ }
68
+ });
69
+ };
70
+
71
+ /**
72
+ * Checks if the given token is in a valid JWT format.
73
+ *
74
+ * @param {string} token - The token to validate.
75
+ * @returns {boolean} - True if the token is in a valid format, false otherwise.
76
+ */
77
+ function isValidRefreshTokenFormat(token) {
78
+ const jwtPattern = /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/;
79
+ return jwtPattern.test(token);
80
+ }
81
+
82
+ module.exports = refreshTokenMiddleware;
@@ -0,0 +1,121 @@
1
+ const rateLimit = require("express-rate-limit");
2
+ const refreshToken = require("./refreshToken");
3
+
4
+ jest.mock("express-rate-limit");
5
+
6
+ describe("refreshLimiter", () => {
7
+ it("should be a rate limiter middleware", () => {
8
+ expect(rateLimit).toHaveBeenCalledWith({
9
+ windowMs: 15 * 60 * 1000,
10
+ max: 100,
11
+ message:
12
+ "Too many refresh requests from this IP, please try again after 15 minutes",
13
+ });
14
+ });
15
+
16
+ it("should be exported as a middleware function", () => {
17
+ expect(typeof refreshToken).toBe("function");
18
+ });
19
+
20
+ it("should use the rate limiter middleware", () => {
21
+ const mockUse = jest.fn();
22
+ const app = { use: mockUse };
23
+
24
+ refreshToken(app);
25
+
26
+ expect(mockUse).toHaveBeenCalledWith(rateLimit());
27
+ });
28
+ });
29
+
30
+ describe("refreshTokenMiddleware", () => {
31
+ const mockRequest = (refreshToken) => ({
32
+ headers: { "x-refresh-token": refreshToken },
33
+ });
34
+ const mockResponse = () => {
35
+ const res = {};
36
+ res.status = jest.fn().mockReturnValue(res);
37
+ res.json = jest.fn().mockReturnValue(res);
38
+ return res;
39
+ };
40
+ const mockNext = jest.fn();
41
+
42
+ beforeEach(() => {
43
+ jest.clearAllMocks();
44
+ });
45
+
46
+ it("should return 401 if no refresh token is provided", async () => {
47
+ const req = mockRequest();
48
+ const res = mockResponse();
49
+
50
+ await refreshTokenMiddleware(req, res, mockNext);
51
+
52
+ expect(res.status).toHaveBeenCalledWith(401);
53
+ expect(res.json).toHaveBeenCalledWith({
54
+ error: "No refresh token provided",
55
+ });
56
+ expect(mockNext).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("should return 400 if refresh token has invalid format", async () => {
60
+ const req = mockRequest("invalid-token");
61
+ const res = mockResponse();
62
+
63
+ await refreshTokenMiddleware(req, res, mockNext);
64
+
65
+ expect(res.status).toHaveBeenCalledWith(400);
66
+ expect(res.json).toHaveBeenCalledWith({
67
+ error: "Invalid refresh token format",
68
+ });
69
+ expect(mockNext).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("should return 401 if unable to refresh token", async () => {
73
+ const req = mockRequest("valid-token");
74
+ const res = mockResponse();
75
+ axios.post.mockRejectedValue({ response: { status: 401 } });
76
+
77
+ await refreshTokenMiddleware(req, res, mockNext);
78
+
79
+ expect(res.status).toHaveBeenCalledWith(401);
80
+ expect(res.json).toHaveBeenCalledWith({ error: "Unable to refresh token" });
81
+ expect(mockNext).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("should return 500 if an error occurs while refreshing token", async () => {
85
+ const req = mockRequest("valid-token");
86
+ const res = mockResponse();
87
+ axios.post.mockRejectedValue(new Error("Some error"));
88
+
89
+ await refreshTokenMiddleware(req, res, mockNext);
90
+
91
+ expect(res.status).toHaveBeenCalledWith(500);
92
+ expect(res.json).toHaveBeenCalledWith({ error: "Error refreshing token" });
93
+ expect(mockNext).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("should set new access token and call next if refresh token is valid", async () => {
97
+ const req = mockRequest("valid-token");
98
+ const res = mockResponse();
99
+ axios.post.mockResolvedValue({
100
+ data: { access_token: "new-access-token" },
101
+ });
102
+
103
+ await refreshTokenMiddleware(req, res, mockNext);
104
+
105
+ expect(refreshTokenCache.has("valid-token")).toBe(true);
106
+ expect(refreshTokenCache.get("valid-token")).toBe("new-access-token");
107
+ expect(req.newAccessToken).toBe("new-access-token");
108
+ expect(mockNext).toHaveBeenCalled();
109
+ });
110
+
111
+ it("should use cached access token and call next if refresh token is already cached", async () => {
112
+ refreshTokenCache.set("valid-token", "cached-access-token");
113
+ const req = mockRequest("valid-token");
114
+ const res = mockResponse();
115
+
116
+ await refreshTokenMiddleware(req, res, mockNext);
117
+
118
+ expect(req.newAccessToken).toBe("cached-access-token");
119
+ expect(mockNext).toHaveBeenCalled();
120
+ });
121
+ });
@@ -0,0 +1,12 @@
1
+ function validateEnvironmentVariables(requiredVars) {
2
+ const missingVars = requiredVars.filter((varName) => !process.env[varName]);
3
+ if (missingVars.length > 0) {
4
+ throw new Error(
5
+ `Missing required environment variables: ${missingVars.join(", ")}`
6
+ );
7
+ }
8
+ }
9
+
10
+ module.exports = {
11
+ validateEnvironmentVariables,
12
+ };