@directus/api 17.0.1 → 18.0.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.
- package/dist/app.js +8 -2
- package/dist/auth/drivers/ldap.js +14 -16
- package/dist/auth/drivers/local.js +16 -10
- package/dist/auth/drivers/oauth2.js +16 -11
- package/dist/auth/drivers/openid.js +16 -11
- package/dist/auth/drivers/saml.js +27 -12
- package/dist/cli/commands/init/index.js +3 -3
- package/dist/cli/commands/security/key.js +2 -2
- package/dist/cli/utils/create-env/env-stub.liquid +19 -4
- package/dist/cli/utils/create-env/index.js +2 -2
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +11 -4
- package/dist/controllers/auth.js +54 -19
- package/dist/controllers/extensions.js +102 -5
- package/dist/controllers/fields.js +0 -3
- package/dist/controllers/items.js +3 -2
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/shares.js +19 -4
- package/dist/database/migrations/20220429A-add-flows.js +3 -3
- package/dist/database/migrations/20230526A-migrate-translation-strings.js +2 -2
- package/dist/database/migrations/20240204A-marketplace.d.ts +3 -0
- package/dist/database/migrations/20240204A-marketplace.js +68 -0
- package/dist/database/migrations/run.js +3 -2
- package/dist/extensions/lib/get-extensions-settings.d.ts +6 -2
- package/dist/extensions/lib/get-extensions-settings.js +70 -22
- package/dist/extensions/lib/get-extensions.d.ts +5 -1
- package/dist/extensions/lib/get-extensions.js +7 -31
- package/dist/extensions/lib/installation/index.d.ts +2 -0
- package/dist/extensions/lib/installation/index.js +9 -0
- package/dist/extensions/lib/installation/manager.d.ts +5 -0
- package/dist/extensions/lib/installation/manager.js +90 -0
- package/dist/extensions/lib/sandbox/generate-api-extensions-sandbox-entrypoint.d.ts +1 -1
- package/dist/extensions/lib/sync-extensions.js +11 -10
- package/dist/extensions/manager.d.ts +27 -25
- package/dist/extensions/manager.js +214 -183
- package/dist/middleware/authenticate.d.ts +1 -0
- package/dist/middleware/error-handler.js +22 -18
- package/dist/middleware/extract-token.d.ts +6 -5
- package/dist/middleware/extract-token.js +27 -11
- package/dist/middleware/merge-content-versions.d.ts +2 -0
- package/dist/middleware/merge-content-versions.js +26 -0
- package/dist/middleware/respond.js +0 -12
- package/dist/middleware/validate-batch.d.ts +1 -0
- package/dist/operations/item-update/index.js +4 -1
- package/dist/request/agent-with-ip-validation.d.ts +1 -1
- package/dist/request/agent-with-ip-validation.js +5 -1
- package/dist/services/activity.js +3 -3
- package/dist/services/assets.js +2 -3
- package/dist/services/authentication.d.ts +7 -2
- package/dist/services/authentication.js +21 -13
- package/dist/services/extensions.d.ts +4 -8
- package/dist/services/extensions.js +110 -93
- package/dist/services/fields.js +34 -22
- package/dist/services/graphql/index.js +98 -42
- package/dist/services/import-export.js +61 -26
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/mail/index.d.ts +1 -1
- package/dist/services/mail/index.js +4 -2
- package/dist/services/payload.js +2 -2
- package/dist/services/{permissions.d.ts → permissions/index.d.ts} +3 -4
- package/dist/services/{permissions.js → permissions/index.js} +6 -23
- package/dist/services/permissions/lib/with-app-minimal-permissions.d.ts +2 -0
- package/dist/services/permissions/lib/with-app-minimal-permissions.js +13 -0
- package/dist/services/relations.d.ts +2 -3
- package/dist/services/relations.js +2 -2
- package/dist/services/roles.d.ts +9 -4
- package/dist/services/roles.js +51 -3
- package/dist/services/server.js +3 -0
- package/dist/services/shares.d.ts +3 -1
- package/dist/services/shares.js +9 -5
- package/dist/storage/index.js +5 -4
- package/dist/types/auth.d.ts +6 -4
- package/dist/types/graphql.d.ts +1 -0
- package/dist/utils/apply-query.js +3 -3
- package/dist/utils/filter-items.d.ts +2 -2
- package/dist/utils/filter-items.js +1 -3
- package/dist/utils/get-cache-headers.d.ts +1 -0
- package/dist/utils/get-cache-key.d.ts +1 -0
- package/dist/utils/get-graphql-query-and-variables.d.ts +1 -0
- package/dist/utils/get-ip-from-req.d.ts +1 -0
- package/dist/utils/get-milliseconds.d.ts +1 -1
- package/dist/utils/get-milliseconds.js +4 -1
- package/dist/utils/is-login-redirect-allowed.d.ts +4 -0
- package/dist/utils/is-login-redirect-allowed.js +34 -0
- package/dist/utils/is-url-allowed.d.ts +1 -1
- package/dist/utils/is-url-allowed.js +5 -5
- package/dist/utils/is-valid-uuid.d.ts +3 -0
- package/dist/utils/is-valid-uuid.js +21 -0
- package/dist/utils/jwt.d.ts +1 -1
- package/dist/utils/jwt.js +3 -3
- package/dist/utils/merge-version-data.d.ts +3 -0
- package/dist/utils/merge-version-data.js +134 -0
- package/dist/utils/sanitize-query.js +2 -0
- package/dist/utils/should-skip-cache.d.ts +1 -0
- package/dist/utils/validate-keys.js +2 -2
- package/dist/utils/validate-query.js +1 -0
- package/dist/websocket/controllers/base.js +2 -2
- package/dist/websocket/controllers/hooks.js +1 -1
- package/package.json +50 -51
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
|
|
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: [
|
|
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
|
|
317
|
-
const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body,
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
323
|
+
payload.refresh_token = refreshToken;
|
|
323
324
|
}
|
|
324
325
|
if (mode === 'cookie') {
|
|
325
|
-
res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken,
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 {
|
|
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
|
|
70
|
-
const { accessToken, refreshToken, expires } = await authenticationService.login(provider, req.body,
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 =
|
|
83
|
-
const roleID =
|
|
82
|
+
const userID = randomUUID();
|
|
83
|
+
const roleID = randomUUID();
|
|
84
84
|
await db('directus_roles').insert({
|
|
85
85
|
id: roleID,
|
|
86
86
|
...defaultAdminRole,
|
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
20
|
+
KEY: randomUUID(),
|
|
21
21
|
SECRET: nanoid(32),
|
|
22
22
|
},
|
|
23
23
|
database: {
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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']
|
|
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 */
|
package/dist/controllers/auth.js
CHANGED
|
@@ -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 {
|
|
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
|
|
87
|
+
const mode = getCurrentMode(req);
|
|
88
|
+
const currentRefreshToken = getCurrentRefreshToken(req, mode);
|
|
61
89
|
if (!currentRefreshToken) {
|
|
62
|
-
throw new InvalidPayloadError({
|
|
90
|
+
throw new InvalidPayloadError({
|
|
91
|
+
reason: `The refresh token is required in either the payload or cookie`,
|
|
92
|
+
});
|
|
63
93
|
}
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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,
|
|
103
|
+
res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
|
|
104
|
+
payload.access_token = accessToken;
|
|
74
105
|
}
|
|
75
|
-
|
|
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
|
|
127
|
+
const mode = getCurrentMode(req);
|
|
128
|
+
const currentRefreshToken = getCurrentRefreshToken(req, mode);
|
|
94
129
|
if (!currentRefreshToken) {
|
|
95
|
-
throw new InvalidPayloadError({
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|