propro-utils 1.7.53 → 1.7.55

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.
@@ -1,5 +1,6 @@
1
1
  require('dotenv').config();
2
2
  const axios = require('axios');
3
+ const crypto = require('crypto');
3
4
  const { getOrSetCache } = require('../utils/redis');
4
5
  const { checkIfUserExists } = require('./account_info');
5
6
  const ServiceManager = require('../utils/serviceManager');
@@ -34,6 +35,41 @@ const ServiceManager = require('../utils/serviceManager');
34
35
  * res.json({ message: 'You have access to protected data' });
35
36
  * });
36
37
  */
38
+ const USER_CACHE_TTL_SECONDS = 60;
39
+ const IN_PROCESS_AUTH_CACHE_TTL_MS = 5000;
40
+ const MAX_IN_PROCESS_CACHE_ENTRIES = 1000;
41
+ const AUTH_SERVICE_TIMEOUT_MS = 10000;
42
+ const validationMemoryCache = new Map();
43
+ const userMemoryCache = new Map();
44
+
45
+ const hashValue = value => crypto.createHash('sha256').update(value).digest('hex');
46
+ const permissionsCachePart = permissions => [...permissions].sort().join(',');
47
+
48
+ const getFromMemoryCache = (cache, key) => {
49
+ const entry = cache.get(key);
50
+ if (!entry) return null;
51
+ if (entry.expiresAt <= Date.now()) {
52
+ cache.delete(key);
53
+ return null;
54
+ }
55
+ return entry.value;
56
+ };
57
+
58
+ const setInMemoryCache = (cache, key, value) => {
59
+ if (cache.size >= MAX_IN_PROCESS_CACHE_ENTRIES) {
60
+ cache.clear();
61
+ }
62
+ cache.set(key, {
63
+ value,
64
+ expiresAt: Date.now() + IN_PROCESS_AUTH_CACHE_TTL_MS,
65
+ });
66
+ };
67
+
68
+ const clearMemoryCaches = () => {
69
+ validationMemoryCache.clear();
70
+ userMemoryCache.clear();
71
+ };
72
+
37
73
  const authValidation = (requiredPermissions = []) => {
38
74
  return async (req, res, next) => {
39
75
  try {
@@ -51,18 +87,25 @@ const authValidation = (requiredPermissions = []) => {
51
87
  {
52
88
  accessToken: accessToken,
53
89
  requiredPermissions: requiredPermissions,
54
- }
90
+ },
91
+ { timeout: AUTH_SERVICE_TIMEOUT_MS }
55
92
  );
56
93
  return response.data;
57
94
  };
58
95
  const redisClient = await ServiceManager.getService('RedisClient');
59
- const cacheKey = `account:permissions:${accessToken}`;
60
- const { accountId, validPermissions } = await getOrSetCache(
61
- redisClient,
62
- cacheKey,
63
- fetchPermission,
64
- 1800
65
- );
96
+ const tokenHash = hashValue(accessToken);
97
+ const requiredPermissionsKey = permissionsCachePart(requiredPermissions);
98
+ const validationCacheKey = `account:permissions:${tokenHash}:${requiredPermissionsKey}`;
99
+ const validationResult =
100
+ getFromMemoryCache(validationMemoryCache, validationCacheKey) ||
101
+ await getOrSetCache(
102
+ redisClient,
103
+ validationCacheKey,
104
+ fetchPermission,
105
+ 1800
106
+ );
107
+ setInMemoryCache(validationMemoryCache, validationCacheKey, validationResult);
108
+ const { accountId, validPermissions } = validationResult;
66
109
 
67
110
  if (!validPermissions) {
68
111
  return res.status(403).json({ error: 'Invalid permissions' });
@@ -72,8 +115,18 @@ const authValidation = (requiredPermissions = []) => {
72
115
 
73
116
  let user = null;
74
117
  try {
75
- user = await checkIfUserExists(accountId);
76
- if (!user) throw new Error('User not found');
118
+ const userCacheKey = `account:user:${accountId}`;
119
+ user = getFromMemoryCache(userMemoryCache, userCacheKey);
120
+ if (!user) {
121
+ user = await getOrSetCache(
122
+ redisClient,
123
+ userCacheKey,
124
+ () => checkIfUserExists(accountId),
125
+ USER_CACHE_TTL_SECONDS
126
+ );
127
+ setInMemoryCache(userMemoryCache, userCacheKey, user);
128
+ }
129
+ if (!user?.id) throw new Error('User not found');
77
130
  } catch (error) {
78
131
  return res.status(403).json({error: error?.message || 'User not found'});
79
132
  }
@@ -82,11 +135,21 @@ const authValidation = (requiredPermissions = []) => {
82
135
  next();
83
136
  } catch (error) {
84
137
  if (error.response && error.response.status) {
85
- next(new Error(error.response.data.message));
138
+ return next(new Error(error.response.data.message));
139
+ }
140
+ if (error.code === 'ECONNABORTED') {
141
+ console.error(`[auth] Auth service timed out after ${AUTH_SERVICE_TIMEOUT_MS}ms: ${process.env.AUTH_URL}`);
142
+ return next(new Error('Auth service timeout'));
143
+ }
144
+ if (error.code) {
145
+ console.error(`[auth] Auth service unreachable (${error.code}): ${process.env.AUTH_URL}`, error.message);
146
+ return next(new Error('Auth service unreachable'));
86
147
  }
87
- next(new Error('Error validating token'));
148
+ console.error('[auth] Unexpected error during token validation:', error);
149
+ return next(new Error('Error validating token'));
88
150
  }
89
151
  };
90
152
  };
91
153
 
92
154
  module.exports = authValidation;
155
+ module.exports.clearMemoryCaches = clearMemoryCaches;
@@ -1,84 +1,124 @@
1
+ jest.mock('axios');
2
+ jest.mock('../utils/redis', () => ({
3
+ getOrSetCache: jest.fn(),
4
+ }));
5
+ jest.mock('../utils/serviceManager', () => ({
6
+ getService: jest.fn(),
7
+ }));
8
+ jest.mock('./account_info', () => ({
9
+ checkIfUserExists: jest.fn(),
10
+ }));
11
+
1
12
  const authValidation = require('./access_token');
2
13
  const axios = require('axios');
3
- const { createMockExpressContext } = require('../utils/testUtils');
14
+ const { getOrSetCache } = require('../utils/redis');
15
+ const ServiceManager = require('../utils/serviceManager');
16
+ const { checkIfUserExists } = require('./account_info');
4
17
 
5
- jest.mock('axios');
18
+ const createReq = () => ({
19
+ cookies: {},
20
+ headers: {},
21
+ });
22
+
23
+ const createRes = () => ({
24
+ status: jest.fn().mockReturnThis(),
25
+ json: jest.fn(),
26
+ });
6
27
 
7
28
  describe('authValidation middleware', () => {
8
- let req, res, next;
29
+ let req;
30
+ let res;
31
+ let next;
32
+ let redisClient;
9
33
 
10
34
  beforeEach(() => {
11
- req = createMockExpressContext('/api/test');
12
- res = {
13
- status: jest.fn().mockReturnThis(),
14
- json: jest.fn(),
15
- };
35
+ authValidation.clearMemoryCaches();
36
+ req = createReq();
37
+ res = createRes();
16
38
  next = jest.fn();
39
+ redisClient = { get: jest.fn(), setEx: jest.fn() };
40
+ ServiceManager.getService.mockReturnValue(redisClient);
41
+ getOrSetCache.mockReset();
42
+ checkIfUserExists.mockReset();
43
+ axios.post.mockReset();
17
44
  });
18
45
 
19
- afterEach(() => {
20
- jest.clearAllMocks();
21
- });
22
-
23
- it('should return 403 if access token is missing', async () => {
24
- // const middleware = authValidation({}, {});
46
+ it('returns 403 if access token is missing', async () => {
47
+ const middleware = authValidation(['user']);
25
48
 
26
- // await middleware(req, res, next);
49
+ await middleware(req, res, next);
27
50
 
28
- // expect(res.status).toHaveBeenCalledWith(403);
29
- // expect(res.json).toHaveBeenCalledWith({
30
- // error: 'Access token is required',
31
- // });
32
- // expect(next).not.toHaveBeenCalled();
33
- expect(1).toBe(1);
51
+ expect(res.status).toHaveBeenCalledWith(403);
52
+ expect(res.json).toHaveBeenCalledWith({ error: 'Access token is required' });
53
+ expect(ServiceManager.getService).not.toHaveBeenCalled();
54
+ expect(getOrSetCache).not.toHaveBeenCalled();
55
+ expect(next).not.toHaveBeenCalled();
34
56
  });
35
57
 
36
- // it('should return 403 if permissions are invalid', async () => {
37
- // req.cookies['x-access-token'] = 'testAccessToken';
38
- // axios.post.mockRejectedValue({
39
- // response: { status: 403, data: { message: 'Invalid permissions' } },
40
- // });
41
-
42
- // const middleware = authValidation({}, {}, ['permission1', 'permission2']);
43
-
44
- // await middleware(req, res, next);
45
-
46
- // expect(res.status).toHaveBeenCalledWith(403);
47
- // expect(res.json).toHaveBeenCalledWith({ error: 'Invalid permissions' });
48
- // expect(next).not.toHaveBeenCalled();
49
- // });
50
-
51
- // it('should set req.account and req.user if token is valid and user exists', async () => {
52
- // req.cookies['x-access-token'] = 'testAccessToken';
53
- // axios.post.mockResolvedValue({ data: { accountId: 'testAccountId' } });
54
-
55
- // const userSchemaMock = {
56
- // checkIfUserExists: jest.fn().mockResolvedValue({ id: 'testUserId' }),
57
- // };
58
- // const middleware = authValidation({}, userSchemaMock, [
59
- // 'permission1',
60
- // 'permission2',
61
- // ]);
62
-
63
- // await middleware(req, res, next);
64
-
65
- // expect(req.account).toBe('testAccountId');
66
- // expect(req.user).toBe('testUserId');
67
- // expect(res.status).not.toHaveBeenCalled();
68
- // expect(res.json).not.toHaveBeenCalled();
69
- // expect(next).toHaveBeenCalled();
70
- // });
58
+ it('uses cached token permissions and cached user context on cache hits', async () => {
59
+ req.headers.authorization = 'Bearer token-1';
60
+ getOrSetCache
61
+ .mockResolvedValueOnce({ accountId: 'account-1', validPermissions: true })
62
+ .mockResolvedValueOnce({ id: 'user-1' });
63
+
64
+ const middleware = authValidation(['user']);
65
+ await middleware(req, res, next);
66
+
67
+ expect(ServiceManager.getService).toHaveBeenCalledWith('RedisClient');
68
+ expect(getOrSetCache).toHaveBeenNthCalledWith(
69
+ 1,
70
+ redisClient,
71
+ 'account:permissions:3f08aace122ee2368432c1ca23a049bc640bafbf00fdf33a52429f38ba12dbf9:user',
72
+ expect.any(Function),
73
+ 1800
74
+ );
75
+ expect(getOrSetCache).toHaveBeenNthCalledWith(
76
+ 2,
77
+ redisClient,
78
+ 'account:user:account-1',
79
+ expect.any(Function),
80
+ 60
81
+ );
82
+ expect(axios.post).not.toHaveBeenCalled();
83
+ expect(checkIfUserExists).not.toHaveBeenCalled();
84
+ expect(req.account).toBe('account-1');
85
+ expect(req.user).toBe('user-1');
86
+ expect(next).toHaveBeenCalledWith();
87
+ });
71
88
 
72
- // it('should call next with error if token validation fails', async () => {
73
- // req.cookies['x-access-token'] = 'testAccessToken';
74
- // axios.post.mockRejectedValue(new Error('Token validation error'));
89
+ it('populates the user cache by calling checkIfUserExists on user cache miss', async () => {
90
+ req.cookies['x-access-token'] = 'cookie-token';
91
+ checkIfUserExists.mockResolvedValue({ id: 'created-user' });
92
+ getOrSetCache.mockImplementation(async (_client, key, service) => {
93
+ if (key === 'account:permissions:5f8f34c90be6a68dc1dd28ce60d8716554635c21c29c84b5f4a78ab75d38ab4b:user') {
94
+ return { accountId: 'account-2', validPermissions: true };
95
+ }
96
+ if (key === 'account:user:account-2') {
97
+ return service();
98
+ }
99
+ throw new Error(`unexpected cache key ${key}`);
100
+ });
101
+
102
+ const middleware = authValidation(['user']);
103
+ await middleware(req, res, next);
104
+
105
+ expect(checkIfUserExists).toHaveBeenCalledWith('account-2');
106
+ expect(req.account).toBe('account-2');
107
+ expect(req.user).toBe('created-user');
108
+ expect(next).toHaveBeenCalledWith();
109
+ });
75
110
 
76
- // const middleware = authValidation({}, {});
111
+ it('rejects when the cached or loaded user is invalid', async () => {
112
+ req.headers.authorization = 'Bearer token-2';
113
+ getOrSetCache
114
+ .mockResolvedValueOnce({ accountId: 'account-3', validPermissions: true })
115
+ .mockResolvedValueOnce(null);
77
116
 
78
- // await middleware(req, res, next);
117
+ const middleware = authValidation(['user']);
118
+ await middleware(req, res, next);
79
119
 
80
- // expect(next).toHaveBeenCalledWith(new Error('Error validating token'));
81
- // expect(res.status).not.toHaveBeenCalled();
82
- // expect(res.json).not.toHaveBeenCalled();
83
- // });
120
+ expect(res.status).toHaveBeenCalledWith(403);
121
+ expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
122
+ expect(next).not.toHaveBeenCalled();
123
+ });
84
124
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "propro-utils",
3
- "version": "1.7.53",
3
+ "version": "1.7.55",
4
4
  "description": "Auth middleware for propro-auth",
5
5
  "main": "src/index.js",
6
6
  "private": false,