@wbce-d9/api 11.0.8 → 11.1.1
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/dist/constants.js +13 -5
- package/dist/database/migrations/20250814A-add-session-fallback-token.d.ts +3 -0
- package/dist/database/migrations/20250814A-add-session-fallback-token.js +10 -0
- package/dist/env.js +1 -0
- package/dist/middleware/authenticate.js +58 -44
- package/dist/middleware/extract-token.js +3 -1
- package/dist/services/authentication.js +52 -8
- package/package.json +6 -6
package/dist/constants.js
CHANGED
|
@@ -56,8 +56,12 @@ export const REFRESH_COOKIE_OPTIONS = {
|
|
|
56
56
|
sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
|
|
57
57
|
path: '/auth',
|
|
58
58
|
};
|
|
59
|
-
const
|
|
60
|
-
|
|
59
|
+
export const REFRESH_COOKIE_CLEAR_OPTIONS = (() => {
|
|
60
|
+
const { ...rest } = REFRESH_COOKIE_OPTIONS;
|
|
61
|
+
delete rest.maxAge;
|
|
62
|
+
delete rest.expires;
|
|
63
|
+
return rest;
|
|
64
|
+
})();
|
|
61
65
|
export const GET_SET_HEADER = (cookieValue) => {
|
|
62
66
|
const domainHeader = env['REFRESH_TOKEN_COOKIE_DOMAIN'] ? ` Domain=${env['REFRESH_TOKEN_COOKIE_DOMAIN']};` : '';
|
|
63
67
|
const partitionedHeader = env['REFRESH_TOKEN_COOKIE_PARTITIONED'] == true ? ' Partitioned;' : '';
|
|
@@ -70,10 +74,14 @@ export const ACCESS_COOKIE_OPTIONS = {
|
|
|
70
74
|
maxAge: getMilliseconds(env['ACCESS_TOKEN_TTL']),
|
|
71
75
|
secure: env['ACCESS_TOKEN_COOKIE_SECURE'] ?? false,
|
|
72
76
|
sameSite: env['ACCESS_TOKEN_COOKIE_SAME_SITE'] || 'strict',
|
|
73
|
-
path: '/',
|
|
77
|
+
path: '/assets',
|
|
74
78
|
};
|
|
75
|
-
const
|
|
76
|
-
|
|
79
|
+
export const ACCESS_COOKIE_CLEAR_OPTIONS = (() => {
|
|
80
|
+
const { ...rest } = ACCESS_COOKIE_OPTIONS;
|
|
81
|
+
delete rest.maxAge;
|
|
82
|
+
delete rest.expires;
|
|
83
|
+
return rest;
|
|
84
|
+
})();
|
|
77
85
|
export const OAS_REQUIRED_SCHEMAS = ['Diff', 'Schema', 'Query', 'x-metadata'];
|
|
78
86
|
/** Formats from which transformation is supported */
|
|
79
87
|
export const SUPPORTED_IMAGE_TRANSFORM_FORMATS = ['image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/avif'];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
3
|
+
table.string('fallback_token', 64).nullable();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
export async function down(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_sessions', (table) => {
|
|
8
|
+
table.dropColumn('fallback_token');
|
|
9
|
+
});
|
|
10
|
+
}
|
package/dist/env.js
CHANGED
|
@@ -225,6 +225,7 @@ const defaults = {
|
|
|
225
225
|
REFRESH_TOKEN_COOKIE_SECURE: false,
|
|
226
226
|
REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
|
|
227
227
|
REFRESH_TOKEN_COOKIE_NAME: 'directus_refresh_token',
|
|
228
|
+
REFRESH_TOKEN_OVERLAP_DURATION: '10s',
|
|
228
229
|
LOGIN_STALL_TIME: 500,
|
|
229
230
|
SERVER_SHUTDOWN_TIMEOUT: 1000,
|
|
230
231
|
ROOT_REDIRECT: './admin',
|
|
@@ -2,11 +2,12 @@ import { isEqual } from 'lodash-es';
|
|
|
2
2
|
import getDatabase from '../database/index.js';
|
|
3
3
|
import emitter from '../emitter.js';
|
|
4
4
|
import env from '../env.js';
|
|
5
|
-
import { InvalidCredentialsException,
|
|
5
|
+
import { InvalidCredentialsException, InvalidTokenException } from '../exceptions/index.js';
|
|
6
6
|
import asyncHandler from '../utils/async-handler.js';
|
|
7
7
|
import { getIPFromReq } from '../utils/get-ip-from-req.js';
|
|
8
8
|
import isDirectusJWT from '../utils/is-directus-jwt.js';
|
|
9
9
|
import { verifyAccessJWT } from '../utils/jwt.js';
|
|
10
|
+
import { ACCESS_COOKIE_CLEAR_OPTIONS, REFRESH_COOKIE_CLEAR_OPTIONS } from '../constants.js';
|
|
10
11
|
/**
|
|
11
12
|
* Verify the passed JWT and assign the user ID and role to `req`
|
|
12
13
|
*/
|
|
@@ -36,53 +37,66 @@ export const handler = async (req, _res, next) => {
|
|
|
36
37
|
req.accountability = customAccountability;
|
|
37
38
|
return next();
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
try {
|
|
41
|
+
req.accountability = defaultAccountability;
|
|
42
|
+
if (req.token) {
|
|
43
|
+
if (isDirectusJWT(req.token)) {
|
|
44
|
+
const payload = verifyAccessJWT(req.token, env['SECRET']);
|
|
45
|
+
// Try finding the user with a valid refresh token stored in the access token
|
|
46
|
+
const user = await database
|
|
47
|
+
.select({
|
|
48
|
+
session_expires: 's.expires',
|
|
49
|
+
user_id: 'u.id',
|
|
50
|
+
})
|
|
51
|
+
.from('directus_sessions AS s')
|
|
52
|
+
.leftJoin('directus_users AS u', 's.user', 'u.id')
|
|
53
|
+
.where('s.token', payload.refresh_token)
|
|
54
|
+
.andWhere('s.expires', '>=', new Date())
|
|
55
|
+
.first();
|
|
56
|
+
if (!user) {
|
|
57
|
+
throw new InvalidCredentialsException();
|
|
58
|
+
}
|
|
59
|
+
req.accountability.role = payload.role;
|
|
60
|
+
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
|
61
|
+
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
|
62
|
+
if (payload.share)
|
|
63
|
+
req.accountability.share = payload.share;
|
|
64
|
+
if (payload.share_scope)
|
|
65
|
+
req.accountability.share_scope = payload.share_scope;
|
|
66
|
+
if (payload.id)
|
|
67
|
+
req.accountability.user = payload.id;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Try finding the user with the provided token
|
|
71
|
+
const user = await database
|
|
72
|
+
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
|
73
|
+
.from('directus_users')
|
|
74
|
+
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
|
75
|
+
.where({
|
|
76
|
+
'directus_users.token': req.token,
|
|
77
|
+
status: 'active',
|
|
78
|
+
})
|
|
79
|
+
.first();
|
|
80
|
+
if (!user) {
|
|
81
|
+
throw new InvalidCredentialsException();
|
|
82
|
+
}
|
|
83
|
+
req.accountability.user = user.id;
|
|
84
|
+
req.accountability.role = user.role;
|
|
85
|
+
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
|
86
|
+
req.accountability.app = user.app_access === true || user.app_access == 1;
|
|
56
87
|
}
|
|
57
|
-
req.accountability.role = payload.role;
|
|
58
|
-
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
|
59
|
-
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
|
60
|
-
if (payload.share)
|
|
61
|
-
req.accountability.share = payload.share;
|
|
62
|
-
if (payload.share_scope)
|
|
63
|
-
req.accountability.share_scope = payload.share_scope;
|
|
64
|
-
if (payload.id)
|
|
65
|
-
req.accountability.user = payload.id;
|
|
66
88
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'
|
|
75
|
-
status: 'active',
|
|
76
|
-
})
|
|
77
|
-
.first();
|
|
78
|
-
if (!user) {
|
|
79
|
-
throw new InvalidCredentialsException();
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof InvalidCredentialsException || error instanceof InvalidTokenException) {
|
|
92
|
+
if (req?.cookies[env['ACCESS_TOKEN_COOKIE_NAME']]) {
|
|
93
|
+
_res?.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_CLEAR_OPTIONS);
|
|
94
|
+
}
|
|
95
|
+
if (req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
|
|
96
|
+
_res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_CLEAR_OPTIONS);
|
|
80
97
|
}
|
|
81
|
-
req.accountability.user = user.id;
|
|
82
|
-
req.accountability.role = user.role;
|
|
83
|
-
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
|
84
|
-
req.accountability.app = user.app_access === true || user.app_access == 1;
|
|
85
98
|
}
|
|
99
|
+
throw error;
|
|
86
100
|
}
|
|
87
101
|
return next();
|
|
88
102
|
};
|
|
@@ -22,7 +22,9 @@ const extractToken = (req, _res, next) => {
|
|
|
22
22
|
/**
|
|
23
23
|
* Check if there is an access token stored in the cookies
|
|
24
24
|
*/
|
|
25
|
-
if (req?.cookies?.[env['ACCESS_TOKEN_COOKIE_NAME']]
|
|
25
|
+
if (req?.cookies?.[env['ACCESS_TOKEN_COOKIE_NAME']] &&
|
|
26
|
+
req.path.startsWith('/assets/') &&
|
|
27
|
+
req.method === 'GET') {
|
|
26
28
|
token = req.cookies[env['ACCESS_TOKEN_COOKIE_NAME']];
|
|
27
29
|
}
|
|
28
30
|
/**
|
|
@@ -184,6 +184,7 @@ export class AuthenticationService {
|
|
|
184
184
|
const record = await this.knex
|
|
185
185
|
.select({
|
|
186
186
|
session_expires: 's.expires',
|
|
187
|
+
session_fallback_token: 's.fallback_token',
|
|
187
188
|
user_id: 'u.id',
|
|
188
189
|
user_first_name: 'u.first_name',
|
|
189
190
|
user_last_name: 'u.last_name',
|
|
@@ -251,14 +252,52 @@ export class AuthenticationService {
|
|
|
251
252
|
admin_access: record.role_admin_access,
|
|
252
253
|
});
|
|
253
254
|
}
|
|
254
|
-
|
|
255
|
-
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
255
|
+
// Clean up expired sessions for this user first
|
|
256
256
|
await this.knex('directus_sessions')
|
|
257
|
-
.
|
|
258
|
-
|
|
259
|
-
expires
|
|
260
|
-
|
|
261
|
-
|
|
257
|
+
.delete()
|
|
258
|
+
.where('user', '=', record.user_id)
|
|
259
|
+
.andWhere('expires', '<', new Date());
|
|
260
|
+
let newRefreshToken = record.session_fallback_token ?? nanoid(64);
|
|
261
|
+
const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
|
|
262
|
+
// If a fallback session already exists, just update its expiration
|
|
263
|
+
if (record.session_fallback_token) {
|
|
264
|
+
await this.knex('directus_sessions')
|
|
265
|
+
.update({
|
|
266
|
+
expires: refreshTokenExpiration,
|
|
267
|
+
})
|
|
268
|
+
.where({ token: newRefreshToken });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// Otherwise, create the race condition protection mechanism
|
|
272
|
+
const overlapDuration = getMilliseconds(env['REFRESH_TOKEN_OVERLAP_DURATION'], 10000);
|
|
273
|
+
// Atomic protection: only the first request can create the fallback_token
|
|
274
|
+
const sessionUpdated = await this.knex('directus_sessions')
|
|
275
|
+
.update({
|
|
276
|
+
fallback_token: newRefreshToken,
|
|
277
|
+
expires: new Date(Date.now() + overlapDuration), // Current session expires after the overlapDuration
|
|
278
|
+
})
|
|
279
|
+
.where({ token: refreshToken, fallback_token: null }); // Atomic condition to prevent race conditions
|
|
280
|
+
if (sessionUpdated === 0) {
|
|
281
|
+
// Another request already created the fallback_token, retrieve it
|
|
282
|
+
const existingFallback = await this.knex('directus_sessions')
|
|
283
|
+
.select('fallback_token')
|
|
284
|
+
.where({ token: refreshToken })
|
|
285
|
+
.first();
|
|
286
|
+
newRefreshToken = existingFallback.fallback_token;
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// We successfully created the fallback_token, create the new session
|
|
290
|
+
await this.knex('directus_sessions').insert({
|
|
291
|
+
token: newRefreshToken,
|
|
292
|
+
user: record.user_id,
|
|
293
|
+
share: record.share_id,
|
|
294
|
+
expires: refreshTokenExpiration,
|
|
295
|
+
ip: this.accountability?.ip,
|
|
296
|
+
user_agent: this.accountability?.userAgent,
|
|
297
|
+
origin: this.accountability?.origin,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
262
301
|
const tokenPayload = {
|
|
263
302
|
id: record.user_id,
|
|
264
303
|
role: record.role_id,
|
|
@@ -312,7 +351,12 @@ export class AuthenticationService {
|
|
|
312
351
|
const user = record;
|
|
313
352
|
const provider = getAuthProvider(user.provider);
|
|
314
353
|
await provider.logout(clone(user));
|
|
315
|
-
|
|
354
|
+
// Remove all related sessions (main and fallback)
|
|
355
|
+
await this.knex
|
|
356
|
+
.delete()
|
|
357
|
+
.from('directus_sessions')
|
|
358
|
+
.where('token', refreshToken)
|
|
359
|
+
.orWhere('fallback_token', refreshToken);
|
|
316
360
|
}
|
|
317
361
|
}
|
|
318
362
|
async verifyPassword(userID, password) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbce-d9/api",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.1.1",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -143,20 +143,20 @@
|
|
|
143
143
|
"uuid": "9.0.0",
|
|
144
144
|
"uuid-validate": "0.0.3",
|
|
145
145
|
"wellknown": "0.5.0",
|
|
146
|
-
"@wbce-d9/app": "9.33.2",
|
|
147
146
|
"@wbce-d9/constants": "9.28.0",
|
|
147
|
+
"@wbce-d9/app": "9.33.2",
|
|
148
148
|
"@wbce-d9/extensions-sdk": "9.27.2",
|
|
149
|
-
"@wbce-d9/exceptions": "9.26.4",
|
|
150
149
|
"@wbce-d9/schema": "9.26.4",
|
|
151
150
|
"@wbce-d9/specs": "9.26.5",
|
|
152
151
|
"@wbce-d9/storage": "9.26.4",
|
|
152
|
+
"@wbce-d9/storage-driver-azure": "9.26.9",
|
|
153
153
|
"@wbce-d9/storage-driver-cloudinary": "9.27.2",
|
|
154
154
|
"@wbce-d9/storage-driver-gcs": "9.26.9",
|
|
155
|
-
"@wbce-d9/storage-driver-azure": "9.26.9",
|
|
156
155
|
"@wbce-d9/storage-driver-local": "9.26.9",
|
|
157
|
-
"@wbce-d9/utils": "9.29.2",
|
|
158
156
|
"@wbce-d9/update-check": "9.28.0",
|
|
159
|
-
"@wbce-d9/storage-driver-s3": "9.26.11"
|
|
157
|
+
"@wbce-d9/storage-driver-s3": "9.26.11",
|
|
158
|
+
"@wbce-d9/utils": "9.29.2",
|
|
159
|
+
"@wbce-d9/exceptions": "9.26.4"
|
|
160
160
|
},
|
|
161
161
|
"devDependencies": {
|
|
162
162
|
"@ngneat/falso": "6.4.0",
|