propro-utils 1.7.54 → 1.7.56
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/accessTokenContract.test.js +219 -0
- package/middlewares/access_token.js +48 -5
- package/middlewares/access_token.test.js +10 -3
- package/package.json +1 -1
- package/src/server/index.js +102 -71
- package/src/server/logoutCookies.test.js +157 -0
- package/src/server/middleware/cookieUtils.js +39 -30
- package/src/server/refreshConcurrency.test.js +163 -0
- package/utils/redis.js +29 -10
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for `authValidation` in propro-utils.
|
|
3
|
+
*
|
|
4
|
+
* This middleware guards every /api route. Three things about it are wrong in
|
|
5
|
+
* ways that reach users as the wrong behaviour rather than a clear error:
|
|
6
|
+
*
|
|
7
|
+
* - It answers 403 for an expired or missing access token. The client's axios
|
|
8
|
+
* interceptor keys its refresh-and-retry on 401 alone, so a request that
|
|
9
|
+
* should have transparently refreshed instead fails permanently. It only
|
|
10
|
+
* works today because ~15 call sites hand-roll a checkAccessToken() before
|
|
11
|
+
* every request; anything that forgets is dead in the water.
|
|
12
|
+
*
|
|
13
|
+
* - Its catch block calls next() twice when the auth service returns an HTTP
|
|
14
|
+
* error (there is no `return` on the first), producing ERR_HTTP_HEADERS_SENT,
|
|
15
|
+
* and the app's error handler hardcodes 500 — so "log in again" and "the
|
|
16
|
+
* auth service is down" are indistinguishable to the client.
|
|
17
|
+
*
|
|
18
|
+
* - It caches permissions under the raw access token for 30 minutes with no
|
|
19
|
+
* invalidation, so a revoked token keeps working for up to half an hour.
|
|
20
|
+
*/
|
|
21
|
+
// jest.mock must precede the requires: this package does not hoist mock
|
|
22
|
+
// registrations above CommonJS require() calls, and access_token.js
|
|
23
|
+
// destructures checkIfUserExists at module load.
|
|
24
|
+
// EVERY jest.mock has to sit above EVERY require, not just above the module
|
|
25
|
+
// under test: access_token.js pulls in axios and account_info at load, so a
|
|
26
|
+
// registration made after the first require is too late for all of them.
|
|
27
|
+
jest.mock('axios');
|
|
28
|
+
jest.mock('./account_info', () => ({
|
|
29
|
+
checkIfUserExists: jest.fn(async () => ({ id: 'user-1' })),
|
|
30
|
+
getAccountProfile: jest.fn(),
|
|
31
|
+
updateUserGlobalStyleShortcuts: jest.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const authValidation = require('./access_token');
|
|
35
|
+
const axios = require('axios');
|
|
36
|
+
const { checkIfUserExists } = require('./account_info');
|
|
37
|
+
const ServiceManager = require('../utils/serviceManager');
|
|
38
|
+
|
|
39
|
+
/** Minimal redis double with a real in-memory map, so TTLs are observable. */
|
|
40
|
+
function makeRedis() {
|
|
41
|
+
const entries = new Map();
|
|
42
|
+
return {
|
|
43
|
+
entries,
|
|
44
|
+
get: jest.fn(async key => entries.get(key)?.value ?? null),
|
|
45
|
+
setEx: jest.fn(async (key, ttl, value) => {
|
|
46
|
+
entries.set(key, { value, ttl });
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeRes() {
|
|
52
|
+
const res = {
|
|
53
|
+
statusCode: 200,
|
|
54
|
+
json: jest.fn(() => res),
|
|
55
|
+
status: jest.fn(code => {
|
|
56
|
+
res.statusCode = code;
|
|
57
|
+
return res;
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
return res;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const withToken = token => ({
|
|
64
|
+
cookies: {},
|
|
65
|
+
headers: { authorization: `Bearer ${token}` },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let redis;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
jest.clearAllMocks();
|
|
72
|
+
redis = makeRedis();
|
|
73
|
+
jest.spyOn(ServiceManager, 'getService').mockResolvedValue(redis);
|
|
74
|
+
checkIfUserExists.mockResolvedValue({ id: 'user-1' });
|
|
75
|
+
// authValidation keeps module-level in-process caches (5s TTL) in front of
|
|
76
|
+
// redis. Without this, a hit from a previous test satisfies the next one and
|
|
77
|
+
// the redis assertions below silently pass for the wrong reason.
|
|
78
|
+
authValidation.clearMemoryCaches();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
jest.restoreAllMocks();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('authValidation status codes', () => {
|
|
86
|
+
it('answers 401 when no access token is presented', async () => {
|
|
87
|
+
const res = makeRes();
|
|
88
|
+
|
|
89
|
+
await authValidation(['user'])(
|
|
90
|
+
{ cookies: {}, headers: {} },
|
|
91
|
+
res,
|
|
92
|
+
jest.fn()
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// 403 here means "authenticated but forbidden", which tells the client not
|
|
96
|
+
// to bother refreshing. Missing credentials is 401.
|
|
97
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('answers 401 when the auth service rejects the token', async () => {
|
|
101
|
+
axios.post.mockResolvedValue({
|
|
102
|
+
data: { accountId: null, validPermissions: false },
|
|
103
|
+
});
|
|
104
|
+
const res = makeRes();
|
|
105
|
+
|
|
106
|
+
await authValidation(['user'])(withToken('expired'), res, jest.fn());
|
|
107
|
+
|
|
108
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('answers 403 only when the account genuinely lacks the permission', async () => {
|
|
112
|
+
axios.post.mockResolvedValue({
|
|
113
|
+
data: { accountId: 'acct-1', validPermissions: false, authenticated: true },
|
|
114
|
+
});
|
|
115
|
+
const res = makeRes();
|
|
116
|
+
|
|
117
|
+
await authValidation(['admin'])(withToken('valid'), res, jest.fn());
|
|
118
|
+
|
|
119
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('passes a valid token through with account and user attached', async () => {
|
|
123
|
+
axios.post.mockResolvedValue({
|
|
124
|
+
data: { accountId: 'acct-1', validPermissions: true },
|
|
125
|
+
});
|
|
126
|
+
const req = withToken('valid');
|
|
127
|
+
const next = jest.fn();
|
|
128
|
+
|
|
129
|
+
await authValidation(['user'])(req, makeRes(), next);
|
|
130
|
+
|
|
131
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(next).toHaveBeenCalledWith();
|
|
133
|
+
expect(req.account).toBe('acct-1');
|
|
134
|
+
expect(req.user).toBe('user-1');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('authValidation error handling', () => {
|
|
139
|
+
it('calls next exactly once when the auth service errors', async () => {
|
|
140
|
+
axios.post.mockRejectedValue({
|
|
141
|
+
response: { status: 502, data: { message: 'upstream exploded' } },
|
|
142
|
+
});
|
|
143
|
+
const next = jest.fn();
|
|
144
|
+
|
|
145
|
+
await authValidation(['user'])(withToken('valid'), makeRes(), next);
|
|
146
|
+
|
|
147
|
+
// Twice means ERR_HTTP_HEADERS_SENT and a misleading 500.
|
|
148
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('calls next exactly once when the auth service is unreachable', async () => {
|
|
152
|
+
axios.post.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
153
|
+
const next = jest.fn();
|
|
154
|
+
|
|
155
|
+
await authValidation(['user'])(withToken('valid'), makeRes(), next);
|
|
156
|
+
|
|
157
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('authValidation permission cache', () => {
|
|
162
|
+
it('does not put the raw access token in the cache key', async () => {
|
|
163
|
+
axios.post.mockResolvedValue({
|
|
164
|
+
data: { accountId: 'acct-1', validPermissions: true },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await authValidation(['user'])(withToken('super-secret-token'), makeRes(), jest.fn());
|
|
168
|
+
|
|
169
|
+
const keys = [...redis.entries.keys()];
|
|
170
|
+
expect(keys.length).toBeGreaterThan(0);
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
// A live bearer token sitting in redis under a predictable key is a
|
|
173
|
+
// credential at rest for no reason — hash it.
|
|
174
|
+
expect(key).not.toContain('super-secret-token');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('caches a successful validation for less than the access token lifetime', async () => {
|
|
179
|
+
axios.post.mockResolvedValue({
|
|
180
|
+
data: { accountId: 'acct-1', validPermissions: true },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await authValidation(['user'])(withToken('valid'), makeRes(), jest.fn());
|
|
184
|
+
|
|
185
|
+
const [entry] = [...redis.entries.values()];
|
|
186
|
+
// 1800s outlived the token it described, so a revoked or expired token kept
|
|
187
|
+
// working for up to half an hour.
|
|
188
|
+
expect(entry.ttl).toBeLessThanOrEqual(300);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('never caches a failed validation', async () => {
|
|
192
|
+
axios.post.mockResolvedValue({
|
|
193
|
+
data: { accountId: 'acct-1', validPermissions: false, authenticated: true },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await authValidation(['admin'])(withToken('valid'), makeRes(), jest.fn());
|
|
197
|
+
|
|
198
|
+
// Caching a negative locked the account out for the full TTL even after
|
|
199
|
+
// the permission was granted.
|
|
200
|
+
expect(redis.entries.size).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('separates cache entries by required permission', async () => {
|
|
204
|
+
axios.post.mockResolvedValue({
|
|
205
|
+
data: { accountId: 'acct-1', validPermissions: true },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await authValidation(['user'])(withToken('valid'), makeRes(), jest.fn());
|
|
209
|
+
await authValidation(['admin'])(withToken('valid'), makeRes(), jest.fn());
|
|
210
|
+
|
|
211
|
+
// One key for both would let a 'user' check satisfy an 'admin' route.
|
|
212
|
+
// Count only the permission keys — a successful pass also writes an
|
|
213
|
+
// `account:user:*` entry, which is a different cache.
|
|
214
|
+
const permissionKeys = [...redis.entries.keys()].filter(key =>
|
|
215
|
+
key.startsWith('account:permissions:')
|
|
216
|
+
);
|
|
217
|
+
expect(permissionKeys).toHaveLength(2);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -36,8 +36,20 @@ const ServiceManager = require('../utils/serviceManager');
|
|
|
36
36
|
* });
|
|
37
37
|
*/
|
|
38
38
|
const USER_CACHE_TTL_SECONDS = 60;
|
|
39
|
+
/**
|
|
40
|
+
* How long a successful token validation may be trusted without re-asking the
|
|
41
|
+
* auth service.
|
|
42
|
+
*
|
|
43
|
+
* This is a revocation window, not a performance knob: for its duration a
|
|
44
|
+
* revoked or logged-out token still passes. It was 1800s, which outlives the
|
|
45
|
+
* access tokens it describes — so signing out left a token working for up to
|
|
46
|
+
* half an hour. Five minutes keeps most of the load saving while bounding the
|
|
47
|
+
* window to something defensible.
|
|
48
|
+
*/
|
|
49
|
+
const AUTH_VALIDATION_CACHE_TTL_SECONDS = 300;
|
|
39
50
|
const IN_PROCESS_AUTH_CACHE_TTL_MS = 5000;
|
|
40
51
|
const MAX_IN_PROCESS_CACHE_ENTRIES = 1000;
|
|
52
|
+
const AUTH_SERVICE_TIMEOUT_MS = 10000;
|
|
41
53
|
const validationMemoryCache = new Map();
|
|
42
54
|
const userMemoryCache = new Map();
|
|
43
55
|
|
|
@@ -77,7 +89,12 @@ const authValidation = (requiredPermissions = []) => {
|
|
|
77
89
|
req.headers.authorization?.split(' ')[1];
|
|
78
90
|
|
|
79
91
|
if (!accessToken) {
|
|
80
|
-
|
|
92
|
+
// 401, not 403. Clients read 403 as "authenticated but forbidden" and
|
|
93
|
+
// do not attempt a refresh — an axios interceptor keys retry-after-
|
|
94
|
+
// refresh on 401 alone. Answering 403 to a missing credential turns a
|
|
95
|
+
// recoverable expiry into a permanent failure, and is why callers had
|
|
96
|
+
// to hand-roll a token check before every request.
|
|
97
|
+
return res.status(401).json({ error: 'Access token is required' });
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
const fetchPermission = async () => {
|
|
@@ -86,7 +103,8 @@ const authValidation = (requiredPermissions = []) => {
|
|
|
86
103
|
{
|
|
87
104
|
accessToken: accessToken,
|
|
88
105
|
requiredPermissions: requiredPermissions,
|
|
89
|
-
}
|
|
106
|
+
},
|
|
107
|
+
{ timeout: AUTH_SERVICE_TIMEOUT_MS }
|
|
90
108
|
);
|
|
91
109
|
return response.data;
|
|
92
110
|
};
|
|
@@ -100,12 +118,28 @@ const authValidation = (requiredPermissions = []) => {
|
|
|
100
118
|
redisClient,
|
|
101
119
|
validationCacheKey,
|
|
102
120
|
fetchPermission,
|
|
103
|
-
|
|
121
|
+
AUTH_VALIDATION_CACHE_TTL_SECONDS,
|
|
122
|
+
// Positives only. Caching a denial keeps refusing a request that has
|
|
123
|
+
// since been granted, for the whole TTL — a permission change would
|
|
124
|
+
// appear not to have taken effect.
|
|
125
|
+
result => Boolean(result?.validPermissions)
|
|
104
126
|
);
|
|
105
|
-
|
|
127
|
+
if (validationResult?.validPermissions) {
|
|
128
|
+
setInMemoryCache(validationMemoryCache, validationCacheKey, validationResult);
|
|
129
|
+
}
|
|
106
130
|
const { accountId, validPermissions } = validationResult;
|
|
107
|
-
|
|
131
|
+
|
|
108
132
|
if (!validPermissions) {
|
|
133
|
+
// Distinguish "we don't know who you are" from "we know, and no".
|
|
134
|
+
// Without an accountId the auth service rejected the token itself, so
|
|
135
|
+
// the client should refresh and retry — which it only does on 401. A
|
|
136
|
+
// resolved account that lacks the permission is a genuine 403, and
|
|
137
|
+
// refreshing would not help.
|
|
138
|
+
if (!accountId) {
|
|
139
|
+
return res
|
|
140
|
+
.status(401)
|
|
141
|
+
.json({ error: 'Invalid or expired access token' });
|
|
142
|
+
}
|
|
109
143
|
return res.status(403).json({ error: 'Invalid permissions' });
|
|
110
144
|
}
|
|
111
145
|
|
|
@@ -135,6 +169,15 @@ const authValidation = (requiredPermissions = []) => {
|
|
|
135
169
|
if (error.response && error.response.status) {
|
|
136
170
|
return next(new Error(error.response.data.message));
|
|
137
171
|
}
|
|
172
|
+
if (error.code === 'ECONNABORTED') {
|
|
173
|
+
console.error(`[auth] Auth service timed out after ${AUTH_SERVICE_TIMEOUT_MS}ms: ${process.env.AUTH_URL}`);
|
|
174
|
+
return next(new Error('Auth service timeout'));
|
|
175
|
+
}
|
|
176
|
+
if (error.code) {
|
|
177
|
+
console.error(`[auth] Auth service unreachable (${error.code}): ${process.env.AUTH_URL}`, error.message);
|
|
178
|
+
return next(new Error('Auth service unreachable'));
|
|
179
|
+
}
|
|
180
|
+
console.error('[auth] Unexpected error during token validation:', error);
|
|
138
181
|
return next(new Error('Error validating token'));
|
|
139
182
|
}
|
|
140
183
|
};
|
|
@@ -43,12 +43,15 @@ describe('authValidation middleware', () => {
|
|
|
43
43
|
axios.post.mockReset();
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Was asserting 403. A missing credential is 401: clients read 403 as
|
|
47
|
+
// "authenticated but forbidden" and do not attempt a refresh, so answering
|
|
48
|
+
// 403 turned a recoverable expiry into a permanent failure.
|
|
49
|
+
it('returns 401 if access token is missing', async () => {
|
|
47
50
|
const middleware = authValidation(['user']);
|
|
48
51
|
|
|
49
52
|
await middleware(req, res, next);
|
|
50
53
|
|
|
51
|
-
expect(res.status).toHaveBeenCalledWith(
|
|
54
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
52
55
|
expect(res.json).toHaveBeenCalledWith({ error: 'Access token is required' });
|
|
53
56
|
expect(ServiceManager.getService).not.toHaveBeenCalled();
|
|
54
57
|
expect(getOrSetCache).not.toHaveBeenCalled();
|
|
@@ -65,12 +68,16 @@ describe('authValidation middleware', () => {
|
|
|
65
68
|
await middleware(req, res, next);
|
|
66
69
|
|
|
67
70
|
expect(ServiceManager.getService).toHaveBeenCalledWith('RedisClient');
|
|
71
|
+
// TTL was 1800, which outlived the access tokens it described — a revoked
|
|
72
|
+
// token kept passing for up to half an hour. The trailing predicate keeps
|
|
73
|
+
// denials out of the cache, so a permission grant takes effect at once.
|
|
68
74
|
expect(getOrSetCache).toHaveBeenNthCalledWith(
|
|
69
75
|
1,
|
|
70
76
|
redisClient,
|
|
71
77
|
'account:permissions:3f08aace122ee2368432c1ca23a049bc640bafbf00fdf33a52429f38ba12dbf9:user',
|
|
72
78
|
expect.any(Function),
|
|
73
|
-
|
|
79
|
+
300,
|
|
80
|
+
expect.any(Function)
|
|
74
81
|
);
|
|
75
82
|
expect(getOrSetCache).toHaveBeenNthCalledWith(
|
|
76
83
|
2,
|
package/package.json
CHANGED
package/src/server/index.js
CHANGED
|
@@ -196,83 +196,62 @@ class AuthMiddleware {
|
|
|
196
196
|
});
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
//
|
|
199
|
+
// De-duplicate concurrent refreshes of the same token.
|
|
200
|
+
//
|
|
201
|
+
// The shared promise resolves to the rotated token DATA only. It used to
|
|
202
|
+
// resolve to a finished response body built inside the first request's
|
|
203
|
+
// closure, which meant a waiter was answered with someone else's response:
|
|
204
|
+
// `setAuthCookies` wrote to the first `res`, and `req.query.returnTokens`
|
|
205
|
+
// read the first `req`. A web waiter therefore got 200 with no Set-Cookie,
|
|
206
|
+
// and a desktop waiter queued behind a web caller got no tokens at all —
|
|
207
|
+
// both of which read downstream as a failed session and send the user back
|
|
208
|
+
// to sign-in. Every caller now renders its own response from its own
|
|
209
|
+
// req/res below.
|
|
200
210
|
const lockKey = refreshToken;
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
let refreshPromise = this.refreshLocks.get(lockKey);
|
|
212
|
+
const reusedInFlight = Boolean(refreshPromise);
|
|
213
|
+
|
|
214
|
+
if (!refreshPromise) {
|
|
215
|
+
refreshPromise = this.performTokenRefresh(refreshToken);
|
|
216
|
+
this.refreshLocks.set(lockKey, refreshPromise);
|
|
217
|
+
|
|
218
|
+
// Release as soon as it settles, success or failure. Holding a spent
|
|
219
|
+
// token's lock for 30s after success meant a later, unrelated request
|
|
220
|
+
// was answered from a stale cache instead of performing a real refresh.
|
|
221
|
+
const release = () => {
|
|
222
|
+
if (this.refreshLocks.get(lockKey) === refreshPromise) {
|
|
223
|
+
this.refreshLocks.delete(lockKey);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
refreshPromise.then(release, release);
|
|
227
|
+
} else {
|
|
203
228
|
console.log('Waiting for in-flight refresh request to complete...');
|
|
204
|
-
try {
|
|
205
|
-
const result = await this.refreshLocks.get(lockKey);
|
|
206
|
-
console.log('Reusing result from in-flight refresh request');
|
|
207
|
-
return res.status(200).json(result);
|
|
208
|
-
} catch (error) {
|
|
209
|
-
console.error('In-flight refresh request failed:', error);
|
|
210
|
-
return res.status(401).json({
|
|
211
|
-
error: 'Failed to refresh token',
|
|
212
|
-
message: 'Session refresh failed. Please log in again.',
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
229
|
}
|
|
216
230
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
console.log('Starting refresh token operation...');
|
|
220
|
-
const startTime = Date.now();
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const response = await this.refreshTokens(refreshToken);
|
|
224
|
-
const { account, access, refresh } = response.data;
|
|
225
|
-
|
|
226
|
-
if (!account || !access || !refresh) {
|
|
227
|
-
throw new Error('Invalid or expired refresh token');
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
console.log(`Token refresh successful for account: ${account.accountId}`);
|
|
231
|
-
|
|
232
|
-
const user = await checkIfUserExists(account.accountId);
|
|
231
|
+
try {
|
|
232
|
+
const { account, user, access, refresh } = await refreshPromise;
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return { account, user, access, refresh };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
setAuthCookies(
|
|
241
|
-
res,
|
|
242
|
-
{ access, refresh },
|
|
243
|
-
account,
|
|
244
|
-
user,
|
|
245
|
-
this.options.appUrl
|
|
246
|
-
);
|
|
234
|
+
if (reusedInFlight) {
|
|
235
|
+
console.log('Reusing tokens from in-flight refresh request');
|
|
236
|
+
}
|
|
247
237
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
account: { accountId: account.accountId, email: account.email },
|
|
252
|
-
};
|
|
253
|
-
} catch (error) {
|
|
254
|
-
const status = error?.response?.status;
|
|
255
|
-
console.error(`Token refresh failed after ${Date.now() - startTime}ms (status: ${status}):`, error.message);
|
|
256
|
-
|
|
257
|
-
// Immediately clean up lock on failure to prevent blocking
|
|
258
|
-
this.refreshLocks.delete(lockKey);
|
|
259
|
-
|
|
260
|
-
throw error;
|
|
261
|
-
} finally {
|
|
262
|
-
// Clean up lock after 30 seconds for successful requests
|
|
263
|
-
setTimeout(() => {
|
|
264
|
-
this.refreshLocks.delete(lockKey);
|
|
265
|
-
console.log('Refresh lock cleaned up (delayed)');
|
|
266
|
-
}, 30000);
|
|
238
|
+
const { returnTokens } = req.query;
|
|
239
|
+
if (returnTokens === 'true') {
|
|
240
|
+
return res.status(200).json({ account, user, access, refresh });
|
|
267
241
|
}
|
|
268
|
-
})();
|
|
269
242
|
|
|
270
|
-
|
|
271
|
-
|
|
243
|
+
await setAuthCookies(
|
|
244
|
+
res,
|
|
245
|
+
{ access, refresh },
|
|
246
|
+
account,
|
|
247
|
+
user,
|
|
248
|
+
this.options.appUrl
|
|
249
|
+
);
|
|
272
250
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
251
|
+
return res.status(200).json({
|
|
252
|
+
message: 'Token refreshed successfully',
|
|
253
|
+
account: { accountId: account.accountId, email: account.email },
|
|
254
|
+
});
|
|
276
255
|
} catch (error) {
|
|
277
256
|
console.error('Error refreshing token:', error);
|
|
278
257
|
|
|
@@ -300,17 +279,60 @@ class AuthMiddleware {
|
|
|
300
279
|
}
|
|
301
280
|
};
|
|
302
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Performs one upstream token refresh and returns the rotated material.
|
|
284
|
+
*
|
|
285
|
+
* Deliberately free of req/res: this is the value shared between concurrent
|
|
286
|
+
* callers, so it must not close over any single request's context.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} refreshToken
|
|
289
|
+
* @return {Promise<{account: object, user: object, access: object, refresh: object}>}
|
|
290
|
+
*/
|
|
291
|
+
performTokenRefresh = async refreshToken => {
|
|
292
|
+
const startTime = Date.now();
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const response = await this.refreshTokens(refreshToken);
|
|
296
|
+
const { account, access, refresh } = response.data;
|
|
297
|
+
|
|
298
|
+
if (!account || !access || !refresh) {
|
|
299
|
+
throw new Error('Invalid or expired refresh token');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const user = await checkIfUserExists(account.accountId);
|
|
303
|
+
|
|
304
|
+
console.log(
|
|
305
|
+
`Token refresh successful for account ${account.accountId} in ${
|
|
306
|
+
Date.now() - startTime
|
|
307
|
+
}ms`
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return { account, user, access, refresh };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(
|
|
313
|
+
`Token refresh failed after ${Date.now() - startTime}ms (status: ${
|
|
314
|
+
error?.response?.status
|
|
315
|
+
}):`,
|
|
316
|
+
error.message
|
|
317
|
+
);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
303
322
|
handleLogout = async (req, res) => {
|
|
304
323
|
const refreshToken =
|
|
305
324
|
req.cookies['x-refresh-token'] || req.headers['x-refresh-token'];
|
|
306
325
|
if (!refreshToken) {
|
|
307
|
-
|
|
326
|
+
// appUrl is required: without it the deletions go out host-only and do
|
|
327
|
+
// not match the cookies setAuthCookies wrote on `.mapmap.app`, so the
|
|
328
|
+
// session survives the logout that was meant to end it.
|
|
329
|
+
await clearAuthCookies(res, this.options.appUrl);
|
|
308
330
|
return this.handleAuth(req, res);
|
|
309
331
|
}
|
|
310
332
|
|
|
311
333
|
try {
|
|
312
334
|
await this.logoutUser(refreshToken);
|
|
313
|
-
clearAuthCookies(res);
|
|
335
|
+
await clearAuthCookies(res, this.options.appUrl);
|
|
314
336
|
return this.handleAuth(req, res);
|
|
315
337
|
// this.res.status(200).json({ redirectUrl: this.constructRedirectUrl() });
|
|
316
338
|
} catch (error) {
|
|
@@ -439,7 +461,16 @@ class AuthMiddleware {
|
|
|
439
461
|
proxyToAuthServer = async (req, path) => {
|
|
440
462
|
let accessToken = null;
|
|
441
463
|
if (this.pathRequiresAccessToken(path)) {
|
|
442
|
-
|
|
464
|
+
// Accept the bearer header as well as the cookie, matching what
|
|
465
|
+
// authValidation and handleRefreshToken already do. Cookie-only made
|
|
466
|
+
// every route through here unusable from a native client: Tauri has no
|
|
467
|
+
// cookie jar for the API origin and sends Authorization, so profile,
|
|
468
|
+
// password, email, 2FA and avatar updates all threw 'No access token
|
|
469
|
+
// provided' — which handleProxyError, seeing no error.response, reported
|
|
470
|
+
// as a 500.
|
|
471
|
+
accessToken =
|
|
472
|
+
req.cookies?.['x-access-token'] ||
|
|
473
|
+
req.headers?.authorization?.split(' ')[1];
|
|
443
474
|
if (!accessToken) {
|
|
444
475
|
throw new Error('No access token provided');
|
|
445
476
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for auth cookie lifecycle in propro-utils.
|
|
3
|
+
*
|
|
4
|
+
* The defect under test — `setAuthCookies` derives a cookie domain from
|
|
5
|
+
* `appUrl` (`https://app.mapmap.app` widens to `.mapmap.app` so the API and app
|
|
6
|
+
* subdomains share a session). `handleLogout` then calls `clearAuthCookies(res)`
|
|
7
|
+
* with no `appUrl` at all, so it emits host-only deletions.
|
|
8
|
+
*
|
|
9
|
+
* A `Set-Cookie` deletion only removes a cookie whose domain attribute matches,
|
|
10
|
+
* so nothing is cleared: `x-access-token`, `x-refresh-token`, `user`, `account`
|
|
11
|
+
* and `has_account_token` all survive the logout that was supposed to remove
|
|
12
|
+
* them. Because the client's `isUserLoggedIn()` is just "is there an `account`
|
|
13
|
+
* cookie", the user is redirected to sign-in and immediately bounced back in.
|
|
14
|
+
*
|
|
15
|
+
* The invariant these tests pin down: whatever domain a cookie is written with,
|
|
16
|
+
* logout must clear it with the same one.
|
|
17
|
+
*/
|
|
18
|
+
// jest.mock must precede the requires below: this package's jest setup does
|
|
19
|
+
// not hoist mock registrations above CommonJS require() calls, and
|
|
20
|
+
// src/server/index.js destructures checkIfUserExists at module load — so a
|
|
21
|
+
// mock registered afterwards is captured too late and the real one runs.
|
|
22
|
+
jest.mock('../../middlewares/account_info', () => ({
|
|
23
|
+
checkIfUserExists: jest.fn(async () => ({ id: 'user-1', accountId: 'acct-1' })),
|
|
24
|
+
getAccountProfile: jest.fn(),
|
|
25
|
+
updateUserGlobalStyleShortcuts: jest.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const AuthMiddleware = require('./index');
|
|
29
|
+
const {
|
|
30
|
+
setAuthCookies,
|
|
31
|
+
clearAuthCookies,
|
|
32
|
+
} = require('./middleware/cookieUtils');
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const APP_URL = 'https://app.mapmap.app';
|
|
36
|
+
|
|
37
|
+
const OPTIONS = {
|
|
38
|
+
authUrl: 'https://auth.example.test',
|
|
39
|
+
clientId: 'client-id',
|
|
40
|
+
clientSecret: 'client-secret',
|
|
41
|
+
clientUrl: 'https://app.example.test',
|
|
42
|
+
redirectUri: 'https://app.example.test/api/callback',
|
|
43
|
+
appName: 'MapMap',
|
|
44
|
+
appUrl: APP_URL,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SESSION_COOKIES = [
|
|
48
|
+
'x-refresh-token',
|
|
49
|
+
'x-access-token',
|
|
50
|
+
'user',
|
|
51
|
+
'account',
|
|
52
|
+
'has_account_token',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function makeRes() {
|
|
56
|
+
const res = {
|
|
57
|
+
cookie: jest.fn(),
|
|
58
|
+
clearCookie: jest.fn(),
|
|
59
|
+
redirect: jest.fn(),
|
|
60
|
+
json: jest.fn(() => res),
|
|
61
|
+
send: jest.fn(() => res),
|
|
62
|
+
status: jest.fn(() => res),
|
|
63
|
+
};
|
|
64
|
+
return res;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TOKENS = {
|
|
68
|
+
access: { token: 'access-1', expires: new Date(Date.now() + 9e5) },
|
|
69
|
+
refresh: { token: 'refresh-1', expires: new Date(Date.now() + 9e6) },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** name -> domain the cookie was written/cleared with. */
|
|
73
|
+
function domainsFrom(calls) {
|
|
74
|
+
return calls.reduce((acc, call) => {
|
|
75
|
+
const [name, ...rest] = call;
|
|
76
|
+
const options = rest[rest.length - 1] || {};
|
|
77
|
+
acc[name] = options.domain;
|
|
78
|
+
return acc;
|
|
79
|
+
}, {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('auth cookie lifecycle', () => {
|
|
83
|
+
it('clears every cookie on the domain it was set with', async () => {
|
|
84
|
+
const setRes = makeRes();
|
|
85
|
+
await setAuthCookies(
|
|
86
|
+
setRes,
|
|
87
|
+
TOKENS,
|
|
88
|
+
{ accountId: 'acct-1' },
|
|
89
|
+
{ id: 'user-1' },
|
|
90
|
+
APP_URL
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const clearRes = makeRes();
|
|
94
|
+
await clearAuthCookies(clearRes, APP_URL);
|
|
95
|
+
|
|
96
|
+
const written = domainsFrom(setRes.cookie.mock.calls);
|
|
97
|
+
const cleared = domainsFrom(clearRes.clearCookie.mock.calls);
|
|
98
|
+
|
|
99
|
+
for (const name of SESSION_COOKIES) {
|
|
100
|
+
expect(cleared[name]).toBe(written[name]);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('clears the session cookies when logging out with a refresh token', async () => {
|
|
105
|
+
const middleware = new AuthMiddleware(OPTIONS, {}, {}, {}, {});
|
|
106
|
+
middleware.logoutUser = jest.fn(async () => ({ data: {} }));
|
|
107
|
+
|
|
108
|
+
const setRes = makeRes();
|
|
109
|
+
await setAuthCookies(
|
|
110
|
+
setRes,
|
|
111
|
+
TOKENS,
|
|
112
|
+
{ accountId: 'acct-1' },
|
|
113
|
+
{ id: 'user-1' },
|
|
114
|
+
APP_URL
|
|
115
|
+
);
|
|
116
|
+
const written = domainsFrom(setRes.cookie.mock.calls);
|
|
117
|
+
|
|
118
|
+
const res = makeRes();
|
|
119
|
+
await middleware.handleLogout(
|
|
120
|
+
{ cookies: { 'x-refresh-token': 'refresh-1' }, headers: {}, query: {} },
|
|
121
|
+
res
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const cleared = domainsFrom(res.clearCookie.mock.calls);
|
|
125
|
+
|
|
126
|
+
expect(Object.keys(cleared).sort()).toEqual([...SESSION_COOKIES].sort());
|
|
127
|
+
for (const name of SESSION_COOKIES) {
|
|
128
|
+
expect(cleared[name]).toBe(written[name]);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('revokes the refresh token upstream on logout', async () => {
|
|
133
|
+
const middleware = new AuthMiddleware(OPTIONS, {}, {}, {}, {});
|
|
134
|
+
middleware.logoutUser = jest.fn(async () => ({ data: {} }));
|
|
135
|
+
|
|
136
|
+
await middleware.handleLogout(
|
|
137
|
+
{ cookies: { 'x-refresh-token': 'refresh-1' }, headers: {}, query: {} },
|
|
138
|
+
makeRes()
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(middleware.logoutUser).toHaveBeenCalledWith('refresh-1');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('accepts the refresh token from a header for desktop clients', async () => {
|
|
145
|
+
const middleware = new AuthMiddleware(OPTIONS, {}, {}, {}, {});
|
|
146
|
+
middleware.logoutUser = jest.fn(async () => ({ data: {} }));
|
|
147
|
+
|
|
148
|
+
// Tauri has no cookie jar for the API origin, so it sends the token as a
|
|
149
|
+
// header — the same way handleRefreshToken already accepts it.
|
|
150
|
+
await middleware.handleLogout(
|
|
151
|
+
{ cookies: {}, headers: { 'x-refresh-token': 'refresh-1' }, query: {} },
|
|
152
|
+
makeRes()
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(middleware.logoutUser).toHaveBeenCalledWith('refresh-1');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -78,6 +78,39 @@ const setChromeExtensionCookie = details => {
|
|
|
78
78
|
});
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the cookie domain for an appUrl.
|
|
83
|
+
*
|
|
84
|
+
* Shared by set and clear on purpose. A `Set-Cookie` deletion only removes a
|
|
85
|
+
* cookie whose domain attribute matches, so if these two ever compute the
|
|
86
|
+
* domain differently, logout silently leaves the session cookies in place —
|
|
87
|
+
* which is exactly what happened: cookies were written on `.mapmap.app` and
|
|
88
|
+
* cleared host-only, so `x-access-token`, `x-refresh-token`, `user`, `account`
|
|
89
|
+
* and `has_account_token` all survived, and the client (whose `isUserLoggedIn`
|
|
90
|
+
* is just "is there an account cookie") bounced straight back in.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|undefined} appUrl
|
|
93
|
+
* @return {string|undefined} Cookie domain, or undefined for host-only.
|
|
94
|
+
*/
|
|
95
|
+
const resolveCookieDomain = appUrl => {
|
|
96
|
+
try {
|
|
97
|
+
let domain = appUrl ? new URL(appUrl).hostname : undefined;
|
|
98
|
+
if (domain?.includes('mapmap.app')) {
|
|
99
|
+
domain = '.mapmap.app';
|
|
100
|
+
}
|
|
101
|
+
if (domain?.includes('localhost')) {
|
|
102
|
+
domain = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (domain?.includes('propro.so')) {
|
|
105
|
+
domain = 'propro.so';
|
|
106
|
+
}
|
|
107
|
+
return domain;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error('Invalid appUrl:', { error, appUrl });
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
81
114
|
/**
|
|
82
115
|
* Sets cookies for both web and extension contexts
|
|
83
116
|
*/
|
|
@@ -98,23 +131,7 @@ const setAuthCookies = async (res, tokens, account, user, appUrl) => {
|
|
|
98
131
|
const accessMaxAge =
|
|
99
132
|
new Date(tokens.access.expires).getTime() - currentDateTime.getTime();
|
|
100
133
|
|
|
101
|
-
|
|
102
|
-
let domain;
|
|
103
|
-
try {
|
|
104
|
-
domain = appUrl ? new URL(appUrl).hostname : undefined;
|
|
105
|
-
if (domain?.includes('mapmap.app')) {
|
|
106
|
-
domain = '.mapmap.app';
|
|
107
|
-
}
|
|
108
|
-
if (domain?.includes('localhost')) {
|
|
109
|
-
domain = undefined;
|
|
110
|
-
}
|
|
111
|
-
if (domain?.includes('propro.so')) {
|
|
112
|
-
domain = 'propro.so';
|
|
113
|
-
}
|
|
114
|
-
} catch (error) {
|
|
115
|
-
console.error('Invalid appUrl:', { error, appUrl });
|
|
116
|
-
domain = undefined;
|
|
117
|
-
}
|
|
134
|
+
const domain = resolveCookieDomain(appUrl);
|
|
118
135
|
|
|
119
136
|
const commonAttributes = {
|
|
120
137
|
secure: true,
|
|
@@ -205,19 +222,10 @@ const setAuthCookies = async (res, tokens, account, user, appUrl) => {
|
|
|
205
222
|
* Clears cookies from both web and extension contexts
|
|
206
223
|
*/
|
|
207
224
|
const clearAuthCookies = async (res, appUrl) => {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
domain = '.mapmap.app';
|
|
213
|
-
}
|
|
214
|
-
if (domain?.includes('localhost')) {
|
|
215
|
-
domain = undefined;
|
|
216
|
-
}
|
|
217
|
-
} catch (error) {
|
|
218
|
-
console.error('Invalid appUrl:', error);
|
|
219
|
-
domain = undefined;
|
|
220
|
-
}
|
|
225
|
+
// Must match setAuthCookies exactly — see resolveCookieDomain. This used to
|
|
226
|
+
// omit the propro.so branch as well as being called without an appUrl at all,
|
|
227
|
+
// so it could miss on two counts.
|
|
228
|
+
const domain = resolveCookieDomain(appUrl);
|
|
221
229
|
|
|
222
230
|
const commonAttributes = {
|
|
223
231
|
secure: true,
|
|
@@ -268,4 +276,5 @@ const clearAuthCookies = async (res, appUrl) => {
|
|
|
268
276
|
module.exports = {
|
|
269
277
|
setAuthCookies,
|
|
270
278
|
clearAuthCookies,
|
|
279
|
+
resolveCookieDomain,
|
|
271
280
|
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for `AuthMiddleware.handleRefreshToken` in propro-utils.
|
|
3
|
+
*
|
|
4
|
+
* These test a dependency rather than our own source, deliberately: the
|
|
5
|
+
* behaviour is ours to rely on, the package is ProPro-owned, and the fix ships
|
|
6
|
+
* as a pnpm patch (see patches/). A contract test is the only thing that tells
|
|
7
|
+
* us the patch is still applied after a version bump.
|
|
8
|
+
*
|
|
9
|
+
* The defect under test — `handleRefreshToken` de-duplicates concurrent
|
|
10
|
+
* refreshes by caching the in-flight promise under the refresh token, then
|
|
11
|
+
* replies to every waiter with `res.status(200).json(result)`. But `result` is
|
|
12
|
+
* built inside the *first* request's closure: `setAuthCookies(res, ...)` writes
|
|
13
|
+
* to the first `res`, and `req.query.returnTokens` reads the first `req`. So a
|
|
14
|
+
* waiter gets an HTTP 200 carrying neither its cookies nor its tokens.
|
|
15
|
+
*
|
|
16
|
+
* Downstream that is an auth loop. The waiter still holds an expired access
|
|
17
|
+
* token, its next call 401s, the interceptor exhausts its single retry and
|
|
18
|
+
* redirects to sign-in — on desktop, `checkAccessToken` throws
|
|
19
|
+
* "Invalid refresh response", clears the session and opens another browser
|
|
20
|
+
* window.
|
|
21
|
+
*/
|
|
22
|
+
// jest.mock must precede the requires below: this package's jest setup does
|
|
23
|
+
// not hoist mock registrations above CommonJS require() calls, and
|
|
24
|
+
// src/server/index.js destructures checkIfUserExists at module load — so a
|
|
25
|
+
// mock registered afterwards is captured too late and the real one runs.
|
|
26
|
+
jest.mock('../../middlewares/account_info', () => ({
|
|
27
|
+
checkIfUserExists: jest.fn(async () => ({ id: 'user-1', accountId: 'acct-1' })),
|
|
28
|
+
getAccountProfile: jest.fn(),
|
|
29
|
+
updateUserGlobalStyleShortcuts: jest.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const AuthMiddleware = require('./index');
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const OPTIONS = {
|
|
36
|
+
authUrl: 'https://auth.example.test',
|
|
37
|
+
clientId: 'client-id',
|
|
38
|
+
clientSecret: 'client-secret',
|
|
39
|
+
clientUrl: 'https://app.example.test',
|
|
40
|
+
redirectUri: 'https://app.example.test/api/callback',
|
|
41
|
+
appName: 'MapMap',
|
|
42
|
+
appUrl: 'https://app.mapmap.app',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const REFRESH_TOKEN = 'refresh-token-abc';
|
|
46
|
+
|
|
47
|
+
function makeRes() {
|
|
48
|
+
const res = {
|
|
49
|
+
cookie: jest.fn(),
|
|
50
|
+
clearCookie: jest.fn(),
|
|
51
|
+
redirect: jest.fn(),
|
|
52
|
+
json: jest.fn(() => res),
|
|
53
|
+
send: jest.fn(() => res),
|
|
54
|
+
status: jest.fn(() => res),
|
|
55
|
+
};
|
|
56
|
+
return res;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeReq({ returnTokens } = {}) {
|
|
60
|
+
return {
|
|
61
|
+
cookies: { 'x-refresh-token': REFRESH_TOKEN },
|
|
62
|
+
headers: {},
|
|
63
|
+
query: returnTokens ? { returnTokens: 'true' } : {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Freshly built each test so the instance-level refreshLocks map is clean. */
|
|
68
|
+
function buildMiddleware() {
|
|
69
|
+
const middleware = new AuthMiddleware(OPTIONS, {}, {}, {}, {});
|
|
70
|
+
|
|
71
|
+
// Stub the upstream call so no network is involved and rotation is visible.
|
|
72
|
+
middleware.refreshTokens = jest.fn(async () => ({
|
|
73
|
+
data: {
|
|
74
|
+
account: { accountId: 'acct-1', email: 'user@example.test' },
|
|
75
|
+
access: { token: 'new-access', expires: new Date(Date.now() + 9e5) },
|
|
76
|
+
refresh: { token: 'new-refresh', expires: new Date(Date.now() + 9e6) },
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
return middleware;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The payload a caller actually received, whatever shape it took. */
|
|
84
|
+
function payloadOf(res) {
|
|
85
|
+
expect(res.json).toHaveBeenCalled();
|
|
86
|
+
return res.json.mock.calls[res.json.mock.calls.length - 1][0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('handleRefreshToken — concurrent refresh', () => {
|
|
90
|
+
it('gives every concurrent desktop caller the rotated tokens', async () => {
|
|
91
|
+
const middleware = buildMiddleware();
|
|
92
|
+
const resA = makeRes();
|
|
93
|
+
const resB = makeRes();
|
|
94
|
+
|
|
95
|
+
await Promise.all([
|
|
96
|
+
middleware.handleRefreshToken(makeReq({ returnTokens: true }), resA),
|
|
97
|
+
middleware.handleRefreshToken(makeReq({ returnTokens: true }), resB),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
// One upstream refresh is the point of the lock — that part works.
|
|
101
|
+
expect(middleware.refreshTokens).toHaveBeenCalledTimes(1);
|
|
102
|
+
|
|
103
|
+
for (const res of [resA, resB]) {
|
|
104
|
+
const body = payloadOf(res);
|
|
105
|
+
expect(body.access && body.access.token).toBe('new-access');
|
|
106
|
+
expect(body.refresh && body.refresh.token).toBe('new-refresh');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('sets cookies on every concurrent web caller, not just the first', async () => {
|
|
111
|
+
const middleware = buildMiddleware();
|
|
112
|
+
const resA = makeRes();
|
|
113
|
+
const resB = makeRes();
|
|
114
|
+
|
|
115
|
+
await Promise.all([
|
|
116
|
+
middleware.handleRefreshToken(makeReq(), resA),
|
|
117
|
+
middleware.handleRefreshToken(makeReq(), resB),
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
for (const res of [resA, resB]) {
|
|
121
|
+
const names = res.cookie.mock.calls.map(([name]) => name);
|
|
122
|
+
expect(names).toEqual(expect.arrayContaining(['x-access-token']));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('honours each caller’s own returnTokens, not the first caller’s', async () => {
|
|
127
|
+
const middleware = buildMiddleware();
|
|
128
|
+
const webRes = makeRes();
|
|
129
|
+
const desktopRes = makeRes();
|
|
130
|
+
|
|
131
|
+
// Web starts the refresh and owns the lock; desktop arrives while it is
|
|
132
|
+
// in flight. This mixed pairing is the everyday case for a user signed in
|
|
133
|
+
// on both, and it is where the shared-closure bug bites hardest.
|
|
134
|
+
await Promise.all([
|
|
135
|
+
middleware.handleRefreshToken(makeReq(), webRes),
|
|
136
|
+
middleware.handleRefreshToken(makeReq({ returnTokens: true }), desktopRes),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const desktopBody = payloadOf(desktopRes);
|
|
140
|
+
expect(desktopBody.access && desktopBody.access.token).toBe('new-access');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not hold the lock after the refresh settles', async () => {
|
|
144
|
+
const middleware = buildMiddleware();
|
|
145
|
+
|
|
146
|
+
await middleware.handleRefreshToken(makeReq({ returnTokens: true }), makeRes());
|
|
147
|
+
await middleware.handleRefreshToken(makeReq({ returnTokens: true }), makeRes());
|
|
148
|
+
|
|
149
|
+
// The old token is spent the moment rotation succeeds. Holding its lock for
|
|
150
|
+
// 30s means a later caller is answered from a stale cache instead of being
|
|
151
|
+
// told to re-authenticate.
|
|
152
|
+
expect(middleware.refreshTokens).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects a request with no refresh token', async () => {
|
|
156
|
+
const middleware = buildMiddleware();
|
|
157
|
+
const res = makeRes();
|
|
158
|
+
|
|
159
|
+
await middleware.handleRefreshToken({ cookies: {}, headers: {}, query: {} }, res);
|
|
160
|
+
|
|
161
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
162
|
+
});
|
|
163
|
+
});
|
package/utils/redis.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Read-through cache helper.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} redisClient
|
|
5
|
+
* @param {string} key
|
|
6
|
+
* @param {Function} service Produces the value on a miss.
|
|
7
|
+
* @param {number} [time=1800] TTL in seconds.
|
|
8
|
+
* @param {Function} [shouldCache] Decides whether a freshly produced value is
|
|
9
|
+
* worth storing. Defaults to caching everything, which is the right default
|
|
10
|
+
* for data but the wrong one for authorization decisions: caching a "no"
|
|
11
|
+
* keeps denying a request that has since been granted, for the full TTL.
|
|
12
|
+
* Callers that can distinguish a negative result pass a predicate.
|
|
13
|
+
* @return {Promise<*>}
|
|
14
|
+
*/
|
|
15
|
+
const getOrSetCache = async (
|
|
16
|
+
redisClient,
|
|
17
|
+
key,
|
|
18
|
+
service,
|
|
19
|
+
time = 1800,
|
|
20
|
+
shouldCache = () => true
|
|
21
|
+
) => {
|
|
22
|
+
const cachedValue = await redisClient.get(key);
|
|
23
|
+
if (cachedValue) {
|
|
24
|
+
return JSON.parse(cachedValue);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const value = await service();
|
|
28
|
+
if (shouldCache(value)) {
|
|
8
29
|
await redisClient.setEx(key, time, JSON.stringify(value));
|
|
9
|
-
return value;
|
|
10
|
-
} catch (error) {
|
|
11
|
-
throw error;
|
|
12
30
|
}
|
|
31
|
+
return value;
|
|
13
32
|
};
|
|
14
33
|
|
|
15
34
|
module.exports = { getOrSetCache };
|