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.
- package/middlewares/access_token.js +75 -12
- package/middlewares/access_token.test.js +105 -65
- package/package.json +1 -1
|
@@ -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
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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 {
|
|
14
|
+
const { getOrSetCache } = require('../utils/redis');
|
|
15
|
+
const ServiceManager = require('../utils/serviceManager');
|
|
16
|
+
const { checkIfUserExists } = require('./account_info');
|
|
4
17
|
|
|
5
|
-
|
|
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
|
|
29
|
+
let req;
|
|
30
|
+
let res;
|
|
31
|
+
let next;
|
|
32
|
+
let redisClient;
|
|
9
33
|
|
|
10
34
|
beforeEach(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
49
|
+
await middleware(req, res, next);
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
+
const middleware = authValidation(['user']);
|
|
118
|
+
await middleware(req, res, next);
|
|
79
119
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
120
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
121
|
+
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
|
|
122
|
+
expect(next).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
84
124
|
});
|