@wbce-d9/api 11.1.0 → 11.1.2

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.
@@ -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
+ }
@@ -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,16 @@
1
+ export async function up(knex) {
2
+ await knex.schema.alterTable('directus_activity', (table) => {
3
+ table.string('session_id', 64).nullable();
4
+ });
5
+ await knex.schema.alterTable('directus_sessions', (table) => {
6
+ table.string('session_id', 64).nullable();
7
+ });
8
+ }
9
+ export async function down(knex) {
10
+ await knex.schema.alterTable('directus_activity', (table) => {
11
+ table.dropColumn('session_id');
12
+ });
13
+ await knex.schema.alterTable('directus_sessions', (table) => {
14
+ table.dropColumn('session_id');
15
+ });
16
+ }
package/dist/env.js CHANGED
@@ -43,6 +43,7 @@ const allowedEnvironmentVars = [
43
43
  'REFRESH_TOKEN_COOKIE_SECURE',
44
44
  'REFRESH_TOKEN_COOKIE_SAME_SITE',
45
45
  'REFRESH_TOKEN_COOKIE_NAME',
46
+ 'REFRESH_TOKEN_OVERLAP_DURATION',
46
47
  'LOGIN_STALL_TIME',
47
48
  'PASSWORD_RESET_URL_ALLOW_LIST',
48
49
  'USER_INVITE_URL_ALLOW_LIST',
@@ -225,6 +226,7 @@ const defaults = {
225
226
  REFRESH_TOKEN_COOKIE_SECURE: false,
226
227
  REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
227
228
  REFRESH_TOKEN_COOKIE_NAME: 'directus_refresh_token',
229
+ REFRESH_TOKEN_OVERLAP_DURATION: '10s',
228
230
  LOGIN_STALL_TIME: 500,
229
231
  SERVER_SHUTDOWN_TIMEOUT: 1000,
230
232
  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,68 @@ 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
+ session_id: 's.session_id',
51
+ })
52
+ .from('directus_sessions AS s')
53
+ .leftJoin('directus_users AS u', 's.user', 'u.id')
54
+ .where('s.token', payload.refresh_token)
55
+ .andWhere('s.expires', '>=', new Date())
56
+ .first();
57
+ if (!user) {
58
+ throw new InvalidCredentialsException();
59
+ }
60
+ req.accountability.role = payload.role;
61
+ req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
62
+ req.accountability.app = payload.app_access === true || payload.app_access == 1;
63
+ req.accountability.session_id = user.session_id;
64
+ if (payload.share)
65
+ req.accountability.share = payload.share;
66
+ if (payload.share_scope)
67
+ req.accountability.share_scope = payload.share_scope;
68
+ if (payload.id)
69
+ req.accountability.user = payload.id;
70
+ }
71
+ else {
72
+ // Try finding the user with the provided token
73
+ const user = await database
74
+ .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
75
+ .from('directus_users')
76
+ .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
77
+ .where({
78
+ 'directus_users.token': req.token,
79
+ status: 'active',
80
+ })
81
+ .first();
82
+ if (!user) {
83
+ throw new InvalidCredentialsException();
84
+ }
85
+ req.accountability.user = user.id;
86
+ req.accountability.role = user.role;
87
+ req.accountability.admin = user.admin_access === true || user.admin_access == 1;
88
+ req.accountability.app = user.app_access === true || user.app_access == 1;
56
89
  }
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
90
  }
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();
91
+ }
92
+ catch (error) {
93
+ if (error instanceof InvalidCredentialsException || error instanceof InvalidTokenException) {
94
+ if (req?.cookies[env['ACCESS_TOKEN_COOKIE_NAME']]) {
95
+ _res?.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_CLEAR_OPTIONS);
96
+ }
97
+ if (req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
98
+ _res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_CLEAR_OPTIONS);
80
99
  }
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
100
  }
101
+ throw error;
86
102
  }
87
103
  return next();
88
104
  };
@@ -1,2 +1,2 @@
1
1
  import type { AxiosResponse } from 'axios';
2
- export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any>>;
2
+ export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any, {}>>;
@@ -120,6 +120,7 @@ export class AuthenticationService {
120
120
  }
121
121
  const refreshToken = nanoid(64);
122
122
  const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
123
+ const sessionId = nanoid(64);
123
124
  await this.knex('directus_sessions').insert({
124
125
  token: refreshToken,
125
126
  user: user.id,
@@ -127,6 +128,7 @@ export class AuthenticationService {
127
128
  ip: this.accountability?.ip,
128
129
  user_agent: this.accountability?.userAgent,
129
130
  origin: this.accountability?.origin,
131
+ session_id: sessionId,
130
132
  });
131
133
  const tokenPayload = {
132
134
  id: user.id,
@@ -159,6 +161,7 @@ export class AuthenticationService {
159
161
  origin: this.accountability.origin,
160
162
  collection: 'directus_users',
161
163
  item: user.id,
164
+ session_id: sessionId,
162
165
  });
163
166
  }
164
167
  await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
@@ -184,6 +187,8 @@ export class AuthenticationService {
184
187
  const record = await this.knex
185
188
  .select({
186
189
  session_expires: 's.expires',
190
+ session_fallback_token: 's.fallback_token',
191
+ session_id: 's.session_id',
187
192
  user_id: 'u.id',
188
193
  user_first_name: 'u.first_name',
189
194
  user_last_name: 'u.last_name',
@@ -251,14 +256,54 @@ export class AuthenticationService {
251
256
  admin_access: record.role_admin_access,
252
257
  });
253
258
  }
254
- const newRefreshToken = nanoid(64);
255
- const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
259
+ // Clean up expired sessions for this user first
256
260
  await this.knex('directus_sessions')
257
- .update({
258
- token: newRefreshToken,
259
- expires: refreshTokenExpiration,
260
- })
261
- .where({ token: refreshToken });
261
+ .delete()
262
+ .where('user', '=', record.user_id)
263
+ .andWhere('expires', '<', new Date());
264
+ let newRefreshToken = record.session_fallback_token ?? nanoid(64);
265
+ const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
266
+ const sessionId = record.session_id ?? nanoid(64);
267
+ // If a fallback session already exists, just update its expiration
268
+ if (record.session_fallback_token) {
269
+ await this.knex('directus_sessions')
270
+ .update({
271
+ expires: refreshTokenExpiration,
272
+ })
273
+ .where({ token: newRefreshToken });
274
+ }
275
+ else {
276
+ // Otherwise, create the race condition protection mechanism
277
+ const overlapDuration = getMilliseconds(env['REFRESH_TOKEN_OVERLAP_DURATION'], 10000);
278
+ // Atomic protection: only the first request can create the fallback_token
279
+ const sessionUpdated = await this.knex('directus_sessions')
280
+ .update({
281
+ fallback_token: newRefreshToken,
282
+ expires: new Date(Date.now() + overlapDuration), // Current session expires after the overlapDuration
283
+ })
284
+ .where({ token: refreshToken, fallback_token: null }); // Atomic condition to prevent race conditions
285
+ if (sessionUpdated === 0) {
286
+ // Another request already created the fallback_token, retrieve it
287
+ const existingFallback = await this.knex('directus_sessions')
288
+ .select('fallback_token')
289
+ .where({ token: refreshToken })
290
+ .first();
291
+ newRefreshToken = existingFallback.fallback_token;
292
+ }
293
+ else {
294
+ // We successfully created the fallback_token, create the new session
295
+ await this.knex('directus_sessions').insert({
296
+ token: newRefreshToken,
297
+ user: record.user_id,
298
+ share: record.share_id,
299
+ expires: refreshTokenExpiration,
300
+ ip: this.accountability?.ip,
301
+ user_agent: this.accountability?.userAgent,
302
+ origin: this.accountability?.origin,
303
+ session_id: sessionId,
304
+ });
305
+ }
306
+ }
262
307
  const tokenPayload = {
263
308
  id: record.user_id,
264
309
  role: record.role_id,
@@ -312,7 +357,12 @@ export class AuthenticationService {
312
357
  const user = record;
313
358
  const provider = getAuthProvider(user.provider);
314
359
  await provider.logout(clone(user));
315
- await this.knex.delete().from('directus_sessions').where('token', refreshToken);
360
+ // Remove all related sessions (main and fallback)
361
+ await this.knex
362
+ .delete()
363
+ .from('directus_sessions')
364
+ .where('token', refreshToken)
365
+ .orWhere('fallback_token', refreshToken);
316
366
  }
317
367
  }
318
368
  async verifyPassword(userID, password) {
@@ -162,6 +162,7 @@ export class ItemsService {
162
162
  user_agent: this.accountability.userAgent,
163
163
  origin: this.accountability.origin,
164
164
  item: primaryKey,
165
+ session_id: this.accountability.session_id,
165
166
  });
166
167
  // If revisions are tracked, create revisions record
167
168
  if (this.schema.collections[this.collection].accountability === 'all') {
@@ -496,6 +497,7 @@ export class ItemsService {
496
497
  user_agent: this.accountability.userAgent,
497
498
  origin: this.accountability.origin,
498
499
  item: key,
500
+ session_id: this.accountability?.session_id,
499
501
  })), { bypassLimits: true });
500
502
  if (this.schema.collections[this.collection].accountability === 'all') {
501
503
  const itemsService = new ItemsService(this.collection, {
@@ -680,6 +682,7 @@ export class ItemsService {
680
682
  user_agent: this.accountability.userAgent,
681
683
  origin: this.accountability.origin,
682
684
  item: key,
685
+ session_id: this.accountability.session_id,
683
686
  })), { bypassLimits: true });
684
687
  }
685
688
  });
@@ -61,6 +61,7 @@ export class SharesService extends ItemsService {
61
61
  .where('id', record.share_id);
62
62
  const refreshToken = nanoid(64);
63
63
  const refreshTokenExpiration = new Date(Date.now() + getMilliseconds(env['REFRESH_TOKEN_TTL'], 0));
64
+ const sessionId = nanoid(64);
64
65
  await this.knex('directus_sessions').insert({
65
66
  token: refreshToken,
66
67
  expires: refreshTokenExpiration,
@@ -68,6 +69,7 @@ export class SharesService extends ItemsService {
68
69
  user_agent: this.accountability?.userAgent,
69
70
  origin: this.accountability?.origin,
70
71
  share: record.share_id,
72
+ session_id: sessionId,
71
73
  });
72
74
  await this.knex('directus_sessions').delete().where('expires', '<', new Date());
73
75
  const tokenPayload = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "11.1.0",
3
+ "version": "11.1.2",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -72,7 +72,7 @@
72
72
  "@wbce-d9/format-title": "10.0.0",
73
73
  "argon2": "0.30.3",
74
74
  "async": "3.2.4",
75
- "axios": "^1.8.2",
75
+ "axios": "^1.12.0",
76
76
  "busboy": "1.6.0",
77
77
  "bytes": "3.1.2",
78
78
  "camelcase": "7.0.1",
@@ -143,19 +143,19 @@
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",
146
+ "@wbce-d9/app": "9.33.3",
147
+ "@wbce-d9/exceptions": "9.26.4",
148
+ "@wbce-d9/extensions-sdk": "9.27.3",
147
149
  "@wbce-d9/constants": "9.28.0",
148
- "@wbce-d9/schema": "9.26.4",
149
150
  "@wbce-d9/specs": "9.26.5",
150
- "@wbce-d9/exceptions": "9.26.4",
151
151
  "@wbce-d9/storage": "9.26.4",
152
152
  "@wbce-d9/storage-driver-azure": "9.26.9",
153
- "@wbce-d9/extensions-sdk": "9.27.2",
153
+ "@wbce-d9/storage-driver-cloudinary": "9.27.2",
154
154
  "@wbce-d9/storage-driver-gcs": "9.26.9",
155
155
  "@wbce-d9/storage-driver-s3": "9.26.11",
156
156
  "@wbce-d9/storage-driver-local": "9.26.9",
157
157
  "@wbce-d9/update-check": "9.28.0",
158
- "@wbce-d9/storage-driver-cloudinary": "9.27.2",
158
+ "@wbce-d9/schema": "9.26.4",
159
159
  "@wbce-d9/utils": "9.29.2"
160
160
  },
161
161
  "devDependencies": {
@@ -204,7 +204,7 @@
204
204
  "supertest": "6.3.3",
205
205
  "typescript": "5.0.4",
206
206
  "vitest": "0.30.1",
207
- "@wbce-d9/types": "9.28.0"
207
+ "@wbce-d9/types": "9.28.1"
208
208
  },
209
209
  "optionalDependencies": {
210
210
  "@keyv/redis": "2.5.7",