@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 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 { maxAge: refreshCookieMaxAge, expires: refreshCookieExpires, ...refresh_cookie_clear_options } = REFRESH_COOKIE_OPTIONS;
60
- export const REFRESH_COOKIE_CLEAR_OPTIONS = refresh_cookie_clear_options;
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 { maxAge: accessCookieMaxAge, expires: accessCookieExpires, ...access_cookie_clear_options } = ACCESS_COOKIE_OPTIONS;
76
- export const ACCESS_COOKIE_CLEAR_OPTIONS = access_cookie_clear_options;
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,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -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, TokenExpiredException } from '../exceptions/index.js';
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
- req.accountability = defaultAccountability;
40
- if (req.token) {
41
- if (isDirectusJWT(req.token)) {
42
- const payload = verifyAccessJWT(req.token, env['SECRET']);
43
- // Try finding the user with a valid refresh token stored in the access token
44
- const user = await database
45
- .select({
46
- session_expires: 's.expires',
47
- user_id: 'u.id',
48
- })
49
- .from('directus_sessions AS s')
50
- .leftJoin('directus_users AS u', 's.user', 'u.id')
51
- .where('s.token', payload.refresh_token)
52
- .andWhere('s.expires', '>=', new Date())
53
- .first();
54
- if (!user) {
55
- throw new TokenExpiredException();
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
- else {
68
- // Try finding the user with the provided token
69
- const user = await database
70
- .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
71
- .from('directus_users')
72
- .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
73
- .where({
74
- 'directus_users.token': req.token,
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
- const newRefreshToken = nanoid(64);
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
- .update({
258
- token: newRefreshToken,
259
- expires: refreshTokenExpiration,
260
- })
261
- .where({ token: refreshToken });
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
- await this.knex.delete().from('directus_sessions').where('token', refreshToken);
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.0.8",
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",