@directus/api 17.1.0 → 18.1.0

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.
Files changed (95) hide show
  1. package/dist/app.js +8 -2
  2. package/dist/auth/drivers/ldap.js +14 -16
  3. package/dist/auth/drivers/local.js +16 -10
  4. package/dist/auth/drivers/oauth2.js +16 -11
  5. package/dist/auth/drivers/openid.js +16 -11
  6. package/dist/auth/drivers/saml.js +27 -12
  7. package/dist/cli/commands/init/index.js +3 -3
  8. package/dist/cli/commands/security/key.js +2 -2
  9. package/dist/cli/utils/create-env/env-stub.liquid +19 -4
  10. package/dist/cli/utils/create-env/index.js +2 -2
  11. package/dist/constants.d.ts +2 -1
  12. package/dist/constants.js +11 -4
  13. package/dist/controllers/auth.js +54 -19
  14. package/dist/controllers/extensions.js +102 -5
  15. package/dist/controllers/items.js +3 -2
  16. package/dist/controllers/permissions.js +1 -1
  17. package/dist/controllers/shares.js +19 -4
  18. package/dist/database/migrations/20220429A-add-flows.js +3 -3
  19. package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
  20. package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
  21. package/dist/database/migrations/20240204A-marketplace.js +88 -0
  22. package/dist/database/migrations/run.js +3 -2
  23. package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
  24. package/dist/extensions/lib/get-extensions-settings.js +70 -22
  25. package/dist/extensions/lib/get-extensions.d.ts +5 -1
  26. package/dist/extensions/lib/get-extensions.js +7 -31
  27. package/dist/extensions/lib/installation/index.d.ts +2 -0
  28. package/dist/extensions/lib/installation/index.js +9 -0
  29. package/dist/extensions/lib/installation/manager.d.ts +5 -0
  30. package/dist/extensions/lib/installation/manager.js +90 -0
  31. package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
  32. package/dist/extensions/lib/sync-extensions.js +11 -10
  33. package/dist/extensions/manager.d.ts +27 -25
  34. package/dist/extensions/manager.js +214 -183
  35. package/dist/middleware/authenticate.d.ts +1 -0
  36. package/dist/middleware/error-handler.js +22 -18
  37. package/dist/middleware/extract-token.d.ts +6 -5
  38. package/dist/middleware/extract-token.js +27 -11
  39. package/dist/middleware/merge-content-versions.d.ts +2 -0
  40. package/dist/middleware/merge-content-versions.js +26 -0
  41. package/dist/middleware/respond.js +0 -12
  42. package/dist/middleware/validate-batch.d.ts +1 -0
  43. package/dist/request/agent-with-ip-validation.d.ts +1 -1
  44. package/dist/request/agent-with-ip-validation.js +5 -1
  45. package/dist/services/activity.js +3 -3
  46. package/dist/services/assets.js +2 -3
  47. package/dist/services/authentication.d.ts +7 -2
  48. package/dist/services/authentication.js +21 -13
  49. package/dist/services/extensions.d.ts +4 -8
  50. package/dist/services/extensions.js +110 -93
  51. package/dist/services/fields.js +28 -22
  52. package/dist/services/graphql/index.js +98 -42
  53. package/dist/services/index.d.ts +1 -1
  54. package/dist/services/index.js +1 -1
  55. package/dist/services/mail/index.d.ts +1 -1
  56. package/dist/services/mail/index.js +6 -5
  57. package/dist/services/payload.js +2 -2
  58. package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
  59. package/dist/services/{permissions.js → permissions/index.js} +6 -23
  60. package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
  61. package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
  62. package/dist/services/relations.d.ts +2 -3
  63. package/dist/services/relations.js +2 -2
  64. package/dist/services/roles.js +1 -1
  65. package/dist/services/server.js +3 -0
  66. package/dist/services/shares.d.ts +3 -1
  67. package/dist/services/shares.js +9 -5
  68. package/dist/storage/index.js +5 -4
  69. package/dist/types/auth.d.ts +6 -4
  70. package/dist/types/graphql.d.ts +1 -0
  71. package/dist/utils/apply-query.js +3 -3
  72. package/dist/utils/filter-items.d.ts +2 -2
  73. package/dist/utils/filter-items.js +1 -3
  74. package/dist/utils/get-cache-headers.d.ts +1 -0
  75. package/dist/utils/get-cache-key.d.ts +1 -0
  76. package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
  77. package/dist/utils/get-ip-from-req.d.ts +1 -0
  78. package/dist/utils/get-milliseconds.d.ts +1 -1
  79. package/dist/utils/get-milliseconds.js +4 -1
  80. package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
  81. package/dist/utils/is-login-redirect-allowed.js +34 -0
  82. package/dist/utils/is-url-allowed.d.ts +1 -1
  83. package/dist/utils/is-url-allowed.js +5 -5
  84. package/dist/utils/is-valid-uuid.d.ts +3 -0
  85. package/dist/utils/is-valid-uuid.js +21 -0
  86. package/dist/utils/jwt.d.ts +1 -1
  87. package/dist/utils/jwt.js +3 -3
  88. package/dist/utils/merge-version-data.d.ts +3 -0
  89. package/dist/utils/merge-version-data.js +134 -0
  90. package/dist/utils/sanitize-query.js +2 -0
  91. package/dist/utils/should-skip-cache.d.ts +1 -0
  92. package/dist/utils/validate-keys.js +2 -2
  93. package/dist/utils/validate-query.js +1 -0
  94. package/dist/websocket/controllers/base.js +2 -2
  95. package/package.json +45 -46
package/dist/app.js CHANGED
@@ -107,7 +107,7 @@ export default async function createApp() {
107
107
  app.use(helmet.contentSecurityPolicy(merge({
108
108
  useDefaults: true,
109
109
  directives: {
110
- // Unsafe-eval is required for vue3 / vue-i18n / app extensions
110
+ // Unsafe-eval is required for app extensions
111
111
  scriptSrc: ["'self'", "'unsafe-eval'"],
112
112
  // Even though this is recommended to have enabled, it breaks most local
113
113
  // installations. Making this opt-in rather than opt-out is a little more
@@ -116,7 +116,13 @@ export default async function createApp() {
116
116
  // These are required for MapLibre
117
117
  workerSrc: ["'self'", 'blob:'],
118
118
  childSrc: ["'self'", 'blob:'],
119
- imgSrc: ["'self'", 'data:', 'blob:'],
119
+ imgSrc: [
120
+ "'self'",
121
+ 'data:',
122
+ 'blob:',
123
+ 'https://raw.githubusercontent.com',
124
+ 'https://avatars.githubusercontent.com',
125
+ ],
120
126
  mediaSrc: ["'self'"],
121
127
  connectSrc: ["'self'", 'https://*'],
122
128
  },
@@ -11,8 +11,8 @@ import { AuthenticationService } from '../../services/authentication.js';
11
11
  import { UsersService } from '../../services/users.js';
12
12
  import asyncHandler from '../../utils/async-handler.js';
13
13
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
14
- import { getMilliseconds } from '../../utils/get-milliseconds.js';
15
14
  import { AuthDriver } from '../auth.js';
15
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
16
16
  // 0x2: ACCOUNTDISABLE
17
17
  // 0x10: LOCKOUT
18
18
  // 0x800000: PASSWORD_EXPIRED
@@ -290,7 +290,7 @@ export function createLDAPAuthRouter(provider) {
290
290
  const loginSchema = Joi.object({
291
291
  identifier: Joi.string().required(),
292
292
  password: Joi.string().required(),
293
- mode: Joi.string().valid('cookie', 'json'),
293
+ mode: Joi.string().valid('cookie', 'json', 'session'),
294
294
  otp: Joi.string(),
295
295
  }).unknown();
296
296
  router.post('/', asyncHandler(async (req, res, next) => {
@@ -313,24 +313,22 @@ export function createLDAPAuthRouter(provider) {
313
313
  if (error) {
314
314
  throw new InvalidPayloadError({ reason: error.message });
315
315
  }
316
- const mode = req.body.mode || 'json';
317
- const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body, req.body?.otp);
318
- const payload = {
319
- data: { access_token: accessToken, expires },
320
- };
316
+ const mode = req.body.mode ?? 'json';
317
+ const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body, {
318
+ session: mode === 'session',
319
+ otp: req.body?.otp,
320
+ });
321
+ const payload = { access_token: accessToken, expires };
321
322
  if (mode === 'json') {
322
- payload['data']['refresh_token'] = refreshToken;
323
+ payload.refresh_token = refreshToken;
323
324
  }
324
325
  if (mode === 'cookie') {
325
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, {
326
- httpOnly: true,
327
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
328
- maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
329
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
330
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
331
- });
326
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
327
+ }
328
+ if (mode === 'session') {
329
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
332
330
  }
333
- res.locals['payload'] = payload;
331
+ res.locals['payload'] = { data: payload };
334
332
  return next();
335
333
  }), respond);
336
334
  return router;
@@ -3,7 +3,7 @@ import argon2 from 'argon2';
3
3
  import { Router } from 'express';
4
4
  import Joi from 'joi';
5
5
  import { performance } from 'perf_hooks';
6
- import { COOKIE_OPTIONS } from '../../constants.js';
6
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
7
7
  import { useEnv } from '@directus/env';
8
8
  import { respond } from '../../middleware/respond.js';
9
9
  import { AuthenticationService } from '../../services/authentication.js';
@@ -41,7 +41,7 @@ export function createLocalAuthRouter(provider) {
41
41
  const userLoginSchema = Joi.object({
42
42
  email: Joi.string().email().required(),
43
43
  password: Joi.string().required(),
44
- mode: Joi.string().valid('cookie', 'json'),
44
+ mode: Joi.string().valid('cookie', 'json', 'session'),
45
45
  otp: Joi.string(),
46
46
  }).unknown();
47
47
  router.post('/', asyncHandler(async (req, res, next) => {
@@ -66,18 +66,24 @@ export function createLocalAuthRouter(provider) {
66
66
  await stall(STALL_TIME, timeStart);
67
67
  throw new InvalidPayloadError({ reason: error.message });
68
68
  }
69
- const mode = req.body.mode || 'json';
70
- const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body, req.body?.otp);
71
- const payload = {
72
- data: { access_token: accessToken, expires },
73
- };
69
+ const mode = req.body.mode ?? 'json';
70
+ const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body, {
71
+ session: mode === 'session',
72
+ otp: req.body?.otp,
73
+ });
74
+ const payload = { expires };
74
75
  if (mode === 'json') {
75
- payload['data']['refresh_token'] = refreshToken;
76
+ payload.refresh_token = refreshToken;
77
+ payload.access_token = accessToken;
76
78
  }
77
79
  if (mode === 'cookie') {
78
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, COOKIE_OPTIONS);
80
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
81
+ payload.access_token = accessToken;
82
+ }
83
+ if (mode === 'session') {
84
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
79
85
  }
80
- res.locals['payload'] = payload;
86
+ res.locals['payload'] = { data: payload };
81
87
  return next();
82
88
  }), respond);
83
89
  return router;
@@ -1,11 +1,12 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { ErrorCode, InvalidCredentialsError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, isDirectusError, ServiceUnavailableError, } from '@directus/errors';
2
+ import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, isDirectusError, ServiceUnavailableError, } from '@directus/errors';
3
3
  import { parseJSON } from '@directus/utils';
4
4
  import express, { Router } from 'express';
5
5
  import { flatten } from 'flat';
6
6
  import jwt from 'jsonwebtoken';
7
7
  import { errors, generators, Issuer } from 'openid-client';
8
8
  import { getAuthProvider } from '../../auth.js';
9
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
9
10
  import getDatabase from '../../database/index.js';
10
11
  import emitter from '../../emitter.js';
11
12
  import { useLogger } from '../../logger.js';
@@ -15,7 +16,7 @@ import { UsersService } from '../../services/users.js';
15
16
  import asyncHandler from '../../utils/async-handler.js';
16
17
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
17
18
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
18
- import { getMilliseconds } from '../../utils/get-milliseconds.js';
19
+ import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
19
20
  import { Url } from '../../utils/url.js';
20
21
  import { LocalAuthDriver } from './local.js';
21
22
  export class OAuth2AuthDriver extends LocalAuthDriver {
@@ -219,7 +220,11 @@ export function createOAuth2AuthRouter(providerName) {
219
220
  const provider = getAuthProvider(providerName);
220
221
  const codeVerifier = provider.generateCodeVerifier();
221
222
  const prompt = !!req.query['prompt'];
222
- const token = jwt.sign({ verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'], {
223
+ const redirect = req.query['redirect'];
224
+ if (isLoginRedirectAllowed(redirect, providerName) === false) {
225
+ throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
226
+ }
227
+ const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
223
228
  expiresIn: '5m',
224
229
  issuer: 'directus',
225
230
  });
@@ -259,6 +264,7 @@ export function createOAuth2AuthRouter(providerName) {
259
264
  accountability,
260
265
  schema: req.schema,
261
266
  });
267
+ const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
262
268
  let authResponse;
263
269
  try {
264
270
  res.clearCookie(`oauth2.${providerName}`);
@@ -266,7 +272,7 @@ export function createOAuth2AuthRouter(providerName) {
266
272
  code: req.query['code'],
267
273
  codeVerifier: verifier,
268
274
  state: req.query['state'],
269
- });
275
+ }, { session: authMode === 'session' });
270
276
  }
271
277
  catch (error) {
272
278
  // Prompt user for a new refresh_token if invalidated
@@ -288,13 +294,12 @@ export function createOAuth2AuthRouter(providerName) {
288
294
  }
289
295
  const { accessToken, refreshToken, expires } = authResponse;
290
296
  if (redirect) {
291
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, {
292
- httpOnly: true,
293
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
294
- maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
295
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
296
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
297
- });
297
+ if (authMode === 'session') {
298
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
299
+ }
300
+ else {
301
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
302
+ }
298
303
  return res.redirect(redirect);
299
304
  }
300
305
  res.locals['payload'] = {
@@ -1,11 +1,12 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { ErrorCode, InvalidCredentialsError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, isDirectusError, ServiceUnavailableError, } from '@directus/errors';
2
+ import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderConfigError, InvalidProviderError, InvalidTokenError, isDirectusError, ServiceUnavailableError, } from '@directus/errors';
3
3
  import { parseJSON } from '@directus/utils';
4
4
  import express, { Router } from 'express';
5
5
  import { flatten } from 'flat';
6
6
  import jwt from 'jsonwebtoken';
7
7
  import { errors, generators, Issuer } from 'openid-client';
8
8
  import { getAuthProvider } from '../../auth.js';
9
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
9
10
  import getDatabase from '../../database/index.js';
10
11
  import emitter from '../../emitter.js';
11
12
  import { useLogger } from '../../logger.js';
@@ -15,7 +16,7 @@ import { UsersService } from '../../services/users.js';
15
16
  import asyncHandler from '../../utils/async-handler.js';
16
17
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
17
18
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
18
- import { getMilliseconds } from '../../utils/get-milliseconds.js';
19
+ import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
19
20
  import { Url } from '../../utils/url.js';
20
21
  import { LocalAuthDriver } from './local.js';
21
22
  export class OpenIDAuthDriver extends LocalAuthDriver {
@@ -240,7 +241,11 @@ export function createOpenIDAuthRouter(providerName) {
240
241
  const provider = getAuthProvider(providerName);
241
242
  const codeVerifier = provider.generateCodeVerifier();
242
243
  const prompt = !!req.query['prompt'];
243
- const token = jwt.sign({ verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'], {
244
+ const redirect = req.query['redirect'];
245
+ if (isLoginRedirectAllowed(redirect, providerName) === false) {
246
+ throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
247
+ }
248
+ const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
244
249
  expiresIn: '5m',
245
250
  issuer: 'directus',
246
251
  });
@@ -280,6 +285,7 @@ export function createOpenIDAuthRouter(providerName) {
280
285
  accountability,
281
286
  schema: req.schema,
282
287
  });
288
+ const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
283
289
  let authResponse;
284
290
  try {
285
291
  res.clearCookie(`openid.${providerName}`);
@@ -288,7 +294,7 @@ export function createOpenIDAuthRouter(providerName) {
288
294
  codeVerifier: verifier,
289
295
  state: req.query['state'],
290
296
  iss: req.query['iss'],
291
- });
297
+ }, { session: authMode === 'session' });
292
298
  }
293
299
  catch (error) {
294
300
  // Prompt user for a new refresh_token if invalidated
@@ -311,13 +317,12 @@ export function createOpenIDAuthRouter(providerName) {
311
317
  }
312
318
  const { accessToken, refreshToken, expires } = authResponse;
313
319
  if (redirect) {
314
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, {
315
- httpOnly: true,
316
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
317
- maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
318
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
319
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
320
- });
320
+ if (authMode === 'session') {
321
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
322
+ }
323
+ else {
324
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
325
+ }
321
326
  return res.redirect(redirect);
322
327
  }
323
328
  res.locals['payload'] = {
@@ -1,10 +1,10 @@
1
1
  import * as validator from '@authenio/samlify-node-xmllint';
2
2
  import { useEnv } from '@directus/env';
3
- import { ErrorCode, InvalidCredentialsError, InvalidProviderError, isDirectusError } from '@directus/errors';
3
+ import { ErrorCode, InvalidCredentialsError, InvalidPayloadError, InvalidProviderError, isDirectusError, } from '@directus/errors';
4
4
  import express, { Router } from 'express';
5
5
  import * as samlify from 'samlify';
6
6
  import { getAuthProvider } from '../../auth.js';
7
- import { COOKIE_OPTIONS } from '../../constants.js';
7
+ import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
8
8
  import getDatabase from '../../database/index.js';
9
9
  import emitter from '../../emitter.js';
10
10
  import { useLogger } from '../../logger.js';
@@ -14,6 +14,7 @@ import { UsersService } from '../../services/users.js';
14
14
  import asyncHandler from '../../utils/async-handler.js';
15
15
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
16
16
  import { LocalAuthDriver } from './local.js';
17
+ import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
17
18
  // Register the samlify schema validator
18
19
  samlify.setSchemaValidator(validator);
19
20
  export class SAMLAuthDriver extends LocalAuthDriver {
@@ -40,7 +41,11 @@ export class SAMLAuthDriver extends LocalAuthDriver {
40
41
  const logger = useLogger();
41
42
  const { provider, emailKey, identifierKey, givenNameKey, familyNameKey, allowPublicRegistration } = this.config;
42
43
  const email = payload[emailKey ?? 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'];
43
- const identifier = payload[identifierKey ?? 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'];
44
+ const identifier = payload[identifierKey || 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'];
45
+ if (!identifier) {
46
+ logger.warn(`[SAML] Failed to find user identifier for provider "${provider}"`);
47
+ throw new InvalidCredentialsError();
48
+ }
44
49
  const userID = await this.fetchUserID(identifier);
45
50
  if (userID)
46
51
  return userID;
@@ -89,7 +94,11 @@ export function createSAMLAuthRouter(providerName) {
89
94
  const { context: url } = sp.createLoginRequest(idp, 'redirect');
90
95
  const parsedUrl = new URL(url);
91
96
  if (req.query['redirect']) {
92
- parsedUrl.searchParams.append('RelayState', req.query['redirect']);
97
+ const redirect = req.query['redirect'];
98
+ if (isLoginRedirectAllowed(redirect, providerName) === false) {
99
+ throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
100
+ }
101
+ parsedUrl.searchParams.append('RelayState', redirect);
93
102
  }
94
103
  return res.redirect(parsedUrl.toString());
95
104
  }));
@@ -97,23 +106,24 @@ export function createSAMLAuthRouter(providerName) {
97
106
  const { sp, idp } = getAuthProvider(providerName);
98
107
  const { context } = sp.createLogoutRequest(idp, 'redirect', req.body);
99
108
  const authService = new AuthenticationService({ accountability: req.accountability, schema: req.schema });
100
- if (req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
101
- const currentRefreshToken = req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
102
- if (currentRefreshToken) {
103
- await authService.logout(currentRefreshToken);
104
- res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], COOKIE_OPTIONS);
105
- }
109
+ const sessionCookieName = env['SESSION_COOKIE_NAME'];
110
+ if (req.cookies[sessionCookieName]) {
111
+ await authService.logout(req.cookies[sessionCookieName]);
112
+ res.clearCookie(sessionCookieName, SESSION_COOKIE_OPTIONS);
106
113
  }
107
114
  return res.redirect(context);
108
115
  }));
109
116
  router.post('/acs', express.urlencoded({ extended: false }), asyncHandler(async (req, res, next) => {
110
117
  const logger = useLogger();
111
118
  const relayState = req.body?.RelayState;
119
+ const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
112
120
  try {
113
121
  const { sp, idp } = getAuthProvider(providerName);
114
122
  const { extract } = await sp.parseLoginResponse(idp, 'post', req);
115
123
  const authService = new AuthenticationService({ accountability: req.accountability, schema: req.schema });
116
- const { accessToken, refreshToken, expires } = await authService.login(providerName, extract.attributes);
124
+ const { accessToken, refreshToken, expires } = await authService.login(providerName, extract.attributes, {
125
+ session: authMode === 'session',
126
+ });
117
127
  res.locals['payload'] = {
118
128
  data: {
119
129
  access_token: accessToken,
@@ -122,7 +132,12 @@ export function createSAMLAuthRouter(providerName) {
122
132
  },
123
133
  };
124
134
  if (relayState) {
125
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, COOKIE_OPTIONS);
135
+ if (authMode === 'session') {
136
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
137
+ }
138
+ else {
139
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
140
+ }
126
141
  return res.redirect(relayState);
127
142
  }
128
143
  return next();
@@ -2,8 +2,8 @@ import chalk from 'chalk';
2
2
  import { execa } from 'execa';
3
3
  import inquirer from 'inquirer';
4
4
  import Joi from 'joi';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import ora from 'ora';
6
- import { v4 as uuid } from 'uuid';
7
7
  import runMigrations from '../../../database/migrations/run.js';
8
8
  import runSeed from '../../../database/seeds/run.js';
9
9
  import { generateHash } from '../../../utils/generate-hash.js';
@@ -79,8 +79,8 @@ export default async function init() {
79
79
  },
80
80
  ]);
81
81
  firstUser.password = await generateHash(firstUser.password);
82
- const userID = uuid();
83
- const roleID = uuid();
82
+ const userID = randomUUID();
83
+ const roleID = randomUUID();
84
84
  await db('directus_roles').insert({
85
85
  id: roleID,
86
86
  ...defaultAdminRole,
@@ -1,5 +1,5 @@
1
- import { v4 as uuidv4 } from 'uuid';
1
+ import { randomUUID } from 'node:crypto';
2
2
  export default async function generateKey() {
3
- process.stdout.write(uuidv4());
3
+ process.stdout.write(randomUUID());
4
4
  process.exit(0);
5
5
  }
@@ -204,21 +204,36 @@ STORAGE_LOCAL_ROOT="./uploads"
204
204
  # The duration that the access token is valid ["15m"]
205
205
  ACCESS_TOKEN_TTL="15m"
206
206
 
207
- # The duration that the refresh token is valid, and also how long users stay logged-in to the App ["7d"]
207
+ # The duration that the refresh token is valid. This value should be higher than ACCESS_TOKEN_TTL resp. SESSION_COOKIE_TTL. ["7d"]
208
208
  REFRESH_TOKEN_TTL="7d"
209
209
 
210
- # Whether or not to use a secure cookie for the refresh token in cookie mode [false]
210
+ # Whether or not to set the secure attribute for the refresh token cookie [false]
211
211
  REFRESH_TOKEN_COOKIE_SECURE=false
212
212
 
213
- # Value for sameSite in the refresh token cookie when in cookie mode ["lax"]
213
+ # Value for sameSite in the refresh token cookie ["lax"]
214
214
  REFRESH_TOKEN_COOKIE_SAME_SITE="lax"
215
215
 
216
- # Name of refresh token cookie ["directus_refresh_token"]
216
+ # Name of the refresh token cookie ["directus_refresh_token"]
217
217
  REFRESH_TOKEN_COOKIE_NAME="directus_refresh_token"
218
218
 
219
219
  # Which domain to use for the refresh cookie. Useful for development mode.
220
220
  # REFRESH_TOKEN_COOKIE_DOMAIN
221
221
 
222
+ # The duration that the session cookie/token is valid, and also how long users stay logged-in to the App ["1d"]
223
+ SESSION_COOKIE_TTL="1d"
224
+
225
+ # Whether or not to set the secure attribute for the session cookie [false]
226
+ SESSION_COOKIE_SECURE=false
227
+
228
+ # Value of sameSite for the session cookie ["lax"]
229
+ SESSION_COOKIE_SAME_SITE="lax"
230
+
231
+ # Name of the session cookie ["directus_refresh_token"]
232
+ SESSION_COOKIE_NAME="directus_session_token"
233
+
234
+ # Which domain to use for the session cookie. Useful for development mode.
235
+ # SESSION_COOKIE_DOMAIN
236
+
222
237
  # The duration in milliseconds that a login request will be stalled for,
223
238
  # and it should be greater than the time taken for a login request with an invalid password [500]
224
239
  # LOGIN_STALL_TIME=500
@@ -1,10 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import { Liquid } from 'liquidjs';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { dirname } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import path from 'path';
6
7
  import { promisify } from 'util';
7
- import { v4 as uuid } from 'uuid';
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const readFile = promisify(fs.readFile);
10
10
  const writeFile = promisify(fs.writeFile);
@@ -17,7 +17,7 @@ export default async function createEnv(client, credentials, directory) {
17
17
  const { nanoid } = await import('nanoid');
18
18
  const config = {
19
19
  security: {
20
- KEY: uuid(),
20
+ KEY: randomUUID(),
21
21
  SECRET: nanoid(32),
22
22
  },
23
23
  database: {
@@ -8,7 +8,8 @@ export declare const DEFAULT_AUTH_PROVIDER = "default";
8
8
  export declare const COLUMN_TRANSFORMS: string[];
9
9
  export declare const GENERATE_SPECIAL: string[];
10
10
  export declare const UUID_REGEX = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
11
- export declare const COOKIE_OPTIONS: CookieOptions;
11
+ export declare const REFRESH_COOKIE_OPTIONS: CookieOptions;
12
+ export declare const SESSION_COOKIE_OPTIONS: CookieOptions;
12
13
  export declare const OAS_REQUIRED_SCHEMAS: string[];
13
14
  /** Formats from which transformation is supported */
14
15
  export declare const SUPPORTED_IMAGE_TRANSFORM_FORMATS: string[];
package/dist/constants.js CHANGED
@@ -1,5 +1,5 @@
1
- import { useEnv } from '@directus/env';
2
1
  import { getMilliseconds } from './utils/get-milliseconds.js';
2
+ import { useEnv } from '@directus/env';
3
3
  const env = useEnv();
4
4
  export const SYSTEM_ASSET_ALLOW_LIST = [
5
5
  {
@@ -51,12 +51,19 @@ export const DEFAULT_AUTH_PROVIDER = 'default';
51
51
  export const COLUMN_TRANSFORMS = ['year', 'month', 'day', 'weekday', 'hour', 'minute', 'second'];
52
52
  export const GENERATE_SPECIAL = ['uuid', 'date-created', 'role-created', 'user-created'];
53
53
  export const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
54
- export const COOKIE_OPTIONS = {
54
+ export const REFRESH_COOKIE_OPTIONS = {
55
55
  httpOnly: true,
56
56
  domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
57
57
  maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
58
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
59
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
58
+ secure: Boolean(env['REFRESH_TOKEN_COOKIE_SECURE']),
59
+ sameSite: (env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict'),
60
+ };
61
+ export const SESSION_COOKIE_OPTIONS = {
62
+ httpOnly: true,
63
+ domain: env['SESSION_COOKIE_DOMAIN'],
64
+ maxAge: getMilliseconds(env['SESSION_COOKIE_TTL']),
65
+ secure: Boolean(env['SESSION_COOKIE_SECURE']),
66
+ sameSite: (env['SESSION_COOKIE_SAME_SITE'] || 'strict'),
60
67
  };
61
68
  export const OAS_REQUIRED_SCHEMAS = ['Query', 'x-metadata'];
62
69
  /** Formats from which transformation is supported */
@@ -2,7 +2,7 @@ import { useEnv } from '@directus/env';
2
2
  import { ErrorCode, InvalidPayloadError, isDirectusError } from '@directus/errors';
3
3
  import { Router } from 'express';
4
4
  import { createLDAPAuthRouter, createLocalAuthRouter, createOAuth2AuthRouter, createOpenIDAuthRouter, createSAMLAuthRouter, } from '../auth/drivers/index.js';
5
- import { COOKIE_OPTIONS, DEFAULT_AUTH_PROVIDER } from '../constants.js';
5
+ import { REFRESH_COOKIE_OPTIONS, DEFAULT_AUTH_PROVIDER, SESSION_COOKIE_OPTIONS } from '../constants.js';
6
6
  import { useLogger } from '../logger.js';
7
7
  import { respond } from '../middleware/respond.js';
8
8
  import { AuthenticationService } from '../services/authentication.js';
@@ -10,6 +10,8 @@ import { UsersService } from '../services/users.js';
10
10
  import asyncHandler from '../utils/async-handler.js';
11
11
  import { getAuthProviders } from '../utils/get-auth-providers.js';
12
12
  import { getIPFromReq } from '../utils/get-ip-from-req.js';
13
+ import isDirectusJWT from '../utils/is-directus-jwt.js';
14
+ import { verifyAccessJWT } from '../utils/jwt.js';
13
15
  const router = Router();
14
16
  const env = useEnv();
15
17
  const logger = useLogger();
@@ -42,6 +44,31 @@ for (const authProvider of authProviders) {
42
44
  if (!env['AUTH_DISABLE_DEFAULT']) {
43
45
  router.use('/login', createLocalAuthRouter(DEFAULT_AUTH_PROVIDER));
44
46
  }
47
+ function getCurrentMode(req) {
48
+ if (req.body.mode) {
49
+ return req.body.mode;
50
+ }
51
+ if (req.body.refresh_token) {
52
+ return 'json';
53
+ }
54
+ return 'cookie';
55
+ }
56
+ function getCurrentRefreshToken(req, mode) {
57
+ if (mode === 'json') {
58
+ return req.body.refresh_token;
59
+ }
60
+ if (mode === 'cookie') {
61
+ return req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
62
+ }
63
+ if (mode === 'session') {
64
+ const token = req.cookies[env['SESSION_COOKIE_NAME']];
65
+ if (isDirectusJWT(token)) {
66
+ const payload = verifyAccessJWT(token, env['SECRET']);
67
+ return payload.session;
68
+ }
69
+ }
70
+ return undefined;
71
+ }
45
72
  router.post('/refresh', asyncHandler(async (req, res, next) => {
46
73
  const accountability = {
47
74
  ip: getIPFromReq(req),
@@ -57,22 +84,29 @@ router.post('/refresh', asyncHandler(async (req, res, next) => {
57
84
  accountability: accountability,
58
85
  schema: req.schema,
59
86
  });
60
- const currentRefreshToken = req.body.refresh_token || req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
87
+ const mode = getCurrentMode(req);
88
+ const currentRefreshToken = getCurrentRefreshToken(req, mode);
61
89
  if (!currentRefreshToken) {
62
- throw new InvalidPayloadError({ reason: `"refresh_token" is required in either the JSON payload or Cookie` });
90
+ throw new InvalidPayloadError({
91
+ reason: `The refresh token is required in either the payload or cookie`,
92
+ });
63
93
  }
64
- const mode = req.body.mode || (req.body.refresh_token ? 'json' : 'cookie');
65
- const { accessToken, refreshToken, expires } = await authenticationService.refresh(currentRefreshToken);
66
- const payload = {
67
- data: { access_token: accessToken, expires },
68
- };
94
+ const { accessToken, refreshToken, expires } = await authenticationService.refresh(currentRefreshToken, {
95
+ session: mode === 'session',
96
+ });
97
+ const payload = { expires };
69
98
  if (mode === 'json') {
70
- payload['data']['refresh_token'] = refreshToken;
99
+ payload.refresh_token = refreshToken;
100
+ payload.access_token = accessToken;
71
101
  }
72
102
  if (mode === 'cookie') {
73
- res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, COOKIE_OPTIONS);
103
+ res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
104
+ payload.access_token = accessToken;
74
105
  }
75
- res.locals['payload'] = payload;
106
+ if (mode === 'session') {
107
+ res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
108
+ }
109
+ res.locals['payload'] = { data: payload };
76
110
  return next();
77
111
  }), respond);
78
112
  router.post('/logout', asyncHandler(async (req, res, next) => {
@@ -90,18 +124,19 @@ router.post('/logout', asyncHandler(async (req, res, next) => {
90
124
  accountability: accountability,
91
125
  schema: req.schema,
92
126
  });
93
- const currentRefreshToken = req.body.refresh_token || req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
127
+ const mode = getCurrentMode(req);
128
+ const currentRefreshToken = getCurrentRefreshToken(req, mode);
94
129
  if (!currentRefreshToken) {
95
- throw new InvalidPayloadError({ reason: `"refresh_token" is required in either the JSON payload or Cookie` });
130
+ throw new InvalidPayloadError({
131
+ reason: `The refresh token is required in either the payload or cookie`,
132
+ });
96
133
  }
97
134
  await authenticationService.logout(currentRefreshToken);
98
135
  if (req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
99
- res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], {
100
- httpOnly: true,
101
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
102
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
103
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
104
- });
136
+ res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_OPTIONS);
137
+ }
138
+ if (req.cookies[env['SESSION_COOKIE_NAME']]) {
139
+ res.clearCookie(env['SESSION_COOKIE_NAME'], SESSION_COOKIE_OPTIONS);
105
140
  }
106
141
  return next();
107
142
  }), respond);