@wbce-d9/api 12.6.1 → 12.7.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.
@@ -39,11 +39,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
39
39
  provider: this.getConfig()['provider'],
40
40
  providerPayload: { accessToken: tokenSet.access_token, userInfo },
41
41
  }, { database: getDatabase(), schema: this.schema, accountability: null });
42
- // Update user to update refresh_token and other properties that might have changed
43
- await this.getUserService().updateOne(userId, {
44
- ...updatedUserPayload,
45
- auth_data: userPayload.auth_data ?? null,
46
- });
42
+ // Update user to update refresh_token and other properties that might have changed.
43
+ // Keep the stored refresh_token if the provider didn't issue a new one (e.g. Google when consent was
44
+ // already granted): refresh() still checks it against the provider
45
+ const updateUserPayload = userPayload.auth_data
46
+ ? { ...updatedUserPayload, auth_data: userPayload.auth_data }
47
+ : updatedUserPayload;
48
+ // Skip empty updates, they crash (#127)
49
+ if (Object.keys(updateUserPayload).length > 0) {
50
+ await this.getUserService().updateOne(userId, updateUserPayload);
51
+ }
47
52
  return userId;
48
53
  }
49
54
  // --- NEW USER ----
@@ -131,10 +136,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
131
136
  throw this.handleError(e);
132
137
  }
133
138
  }
139
+ else if (this.getConfig()['requireRefreshToken'] === false) {
140
+ // Opt-in: the provider issued no refresh token (e.g. Google when consent was already granted),
141
+ // so the Directus session is the only source of truth for this user
142
+ logger.debug(`[${this.getClientName()}] No refresh token stored, continuing (REQUIRE_REFRESH_TOKEN=false)`);
143
+ }
134
144
  else {
135
- // If no auth connexion, throw user out
145
+ // If no auth connexion, throw user out. On login, the callback retries once with prompt=consent
146
+ // so the provider can issue a refresh_token
136
147
  logger.error(`No [${this.getClientName()}] Session found`);
137
- throw new InvalidCredentialsException();
148
+ throw new InvalidTokenException();
138
149
  }
139
150
  }
140
151
  handleError = (e) => {
@@ -0,0 +1 @@
1
+ export {};
package/dist/env.js CHANGED
@@ -141,6 +141,7 @@ const allowedEnvironmentVars = [
141
141
  'AUTH_.+_FIRST_NAME_KEY',
142
142
  'AUTH_.+_LAST_NAME_KEY',
143
143
  'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
144
+ 'AUTH_.+_REQUIRE_REFRESH_TOKEN',
144
145
  'AUTH_.+_DEFAULT_ROLE_ID',
145
146
  'AUTH_.+_REGISTER_URL_ALLOW_LIST',
146
147
  'AUTH_.+_ICON',
package/dist/flows.d.ts CHANGED
@@ -22,6 +22,15 @@ declare class FlowManager {
22
22
  private checkManualTriggerAccess;
23
23
  private unload;
24
24
  private executeFlow;
25
+ /**
26
+ * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
27
+ * The data is returned as is when there is nothing to conceal.
28
+ */
29
+ private concealEventPayload;
30
+ /**
31
+ * Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
32
+ * the flow revisions), and against `keyedData` for every other operation.
33
+ */
25
34
  private executeOperation;
26
35
  }
27
36
  export {};
package/dist/flows.js CHANGED
@@ -2,7 +2,7 @@ import * as sharedExceptions from '@wbce-d9/exceptions';
2
2
  import { Action } from '@wbce-d9/constants';
3
3
  import { applyOptionsData, isValidJSON, parseJSON, toArray } from '@wbce-d9/utils';
4
4
  import fastRedact from 'fast-redact';
5
- import { omit, pick } from 'lodash-es';
5
+ import { cloneDeep, omit, pick } from 'lodash-es';
6
6
  import { get } from 'micromustache';
7
7
  import { schedule, validate } from 'node-cron';
8
8
  import getDatabase from './database/index.js';
@@ -15,6 +15,7 @@ import { getMessenger } from './messenger.js';
15
15
  import { ActivityService } from './services/activity.js';
16
16
  import * as services from './services/index.js';
17
17
  import { FlowsService } from './services/flows.js';
18
+ import { PayloadService } from './services/payload.js';
18
19
  import { RevisionsService } from './services/revisions.js';
19
20
  import { constructFlowTree } from './utils/construct-flow-tree.js';
20
21
  import { getSchema } from './utils/get-schema.js';
@@ -273,13 +274,22 @@ class FlowManager {
273
274
  [ACCOUNTABILITY_KEY]: context?.['accountability'] ?? null,
274
275
  [ENV_KEY]: pick(env, env['FLOWS_ENV_ALLOW_LIST'] ? toArray(env['FLOWS_ENV_ALLOW_LIST']) : []),
275
276
  };
277
+ // Concealed fields of the event payload (e.g. passwords, tokens) must never end up in logs or revisions
278
+ const concealedData = await this.concealEventPayload(flow, data, database, schema);
279
+ const concealedKeyedData = {
280
+ ...keyedData,
281
+ [TRIGGER_KEY]: concealedData,
282
+ [LAST_KEY]: concealedData,
283
+ };
276
284
  let nextOperation = flow.operation;
277
285
  let lastOperationStatus = 'unknown';
278
286
  const steps = [];
279
287
  while (nextOperation !== null) {
280
- const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, context);
288
+ const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, concealedKeyedData, context);
281
289
  keyedData[nextOperation.key] = data;
282
290
  keyedData[LAST_KEY] = data;
291
+ concealedKeyedData[nextOperation.key] = data;
292
+ concealedKeyedData[LAST_KEY] = data;
283
293
  lastOperationStatus = status;
284
294
  steps.push({ operation: nextOperation.id, key: nextOperation.key, status, options });
285
295
  nextOperation = successor;
@@ -310,7 +320,7 @@ class FlowManager {
310
320
  item: flow.id,
311
321
  data: {
312
322
  steps: steps,
313
- data: redactLogs(omit(keyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
323
+ data: redactLogs(omit(concealedKeyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
314
324
  },
315
325
  });
316
326
  }
@@ -326,15 +336,35 @@ class FlowManager {
326
336
  }
327
337
  return undefined;
328
338
  }
329
- async executeOperation(operation, keyedData, context = {}) {
339
+ /**
340
+ * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
341
+ * The data is returned as is when there is nothing to conceal.
342
+ */
343
+ async concealEventPayload(flow, data, knex, schema) {
344
+ if (flow.trigger !== 'event')
345
+ return data;
346
+ const { collection, payload } = data;
347
+ if (!schema.collections[collection] || !payload)
348
+ return data;
349
+ const payloadService = new PayloadService(collection, { knex, schema });
350
+ const concealedData = cloneDeep(data);
351
+ concealedData.payload = await payloadService.processValues('read', concealedData.payload);
352
+ return concealedData;
353
+ }
354
+ /**
355
+ * Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
356
+ * the flow revisions), and against `keyedData` for every other operation.
357
+ */
358
+ async executeOperation(operation, keyedData, concealedKeyedData, context = {}) {
330
359
  if (!(operation.type in this.operations)) {
331
360
  logger.warn(`Couldn't find operation ${operation.type}`);
332
361
  return { successor: null, status: 'unknown', data: null, options: null };
333
362
  }
334
363
  const handler = this.operations[operation.type];
335
364
  const options = applyOptionsData(operation.options, keyedData);
365
+ const concealedOptions = applyOptionsData(operation.options, concealedKeyedData);
336
366
  try {
337
- let result = await handler(options, {
367
+ let result = await handler(operation.type === 'log' ? concealedOptions : options, {
338
368
  services,
339
369
  exceptions: { ...exceptions, ...sharedExceptions },
340
370
  env,
@@ -352,7 +382,7 @@ class FlowManager {
352
382
  if (typeof result === 'object' && result !== null) {
353
383
  result = mapValuesDeep(result, (_, value) => (value === undefined ? null : value));
354
384
  }
355
- return { successor: operation.resolve, status: 'resolve', data: result ?? null, options };
385
+ return { successor: operation.resolve, status: 'resolve', data: result ?? null, options: concealedOptions };
356
386
  }
357
387
  catch (error) {
358
388
  let data;
@@ -374,7 +404,7 @@ class FlowManager {
374
404
  successor: operation.reject,
375
405
  status: 'reject',
376
406
  data,
377
- options,
407
+ options: concealedOptions,
378
408
  };
379
409
  }
380
410
  }
@@ -0,0 +1 @@
1
+ export {};
package/dist/logger.js CHANGED
@@ -6,17 +6,18 @@ import { URL } from 'url';
6
6
  import env from './env.js';
7
7
  import { REDACT_TEXT } from './constants.js';
8
8
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
9
+ const REDACTED_PATHS = ['req.headers.authorization', 'req.headers.cookie', 'req.query.access_token'];
9
10
  const pinoOptions = {
10
11
  level: env['LOG_LEVEL'] || 'info',
11
12
  redact: {
12
- paths: ['req.headers.authorization', 'req.headers.cookie'],
13
+ paths: REDACTED_PATHS,
13
14
  censor: REDACT_TEXT,
14
15
  },
15
16
  };
16
17
  export const httpLoggerOptions = {
17
18
  level: env['LOG_LEVEL'] || 'info',
18
19
  redact: {
19
- paths: ['req.headers.authorization', 'req.headers.cookie'],
20
+ paths: REDACTED_PATHS,
20
21
  censor: REDACT_TEXT,
21
22
  },
22
23
  };
@@ -43,7 +44,7 @@ if (env['LOG_STYLE'] !== 'raw') {
43
44
  }
44
45
  if (env['LOG_STYLE'] === 'raw') {
45
46
  httpLoggerOptions.redact = {
46
- paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers'],
47
+ paths: [...REDACTED_PATHS, 'res.headers'],
47
48
  censor: (value, pathParts) => {
48
49
  const path = pathParts.join('.');
49
50
  if (path === 'res.headers') {
@@ -104,6 +104,9 @@ export class GraphQLService {
104
104
  return formattedResult;
105
105
  }
106
106
  getSchema(type = 'schema') {
107
+ if (type === 'sdl' && env['GRAPHQL_INTROSPECTION'] === false) {
108
+ throw new ForbiddenException();
109
+ }
107
110
  // eslint-disable-next-line @typescript-eslint/no-this-alias
108
111
  const self = this;
109
112
  const schemaComposer = new SchemaComposer();
@@ -3,7 +3,7 @@ import { format, isValid, parseISO } from 'date-fns';
3
3
  import flat from 'flat';
4
4
  import Joi from 'joi';
5
5
  import { clone, cloneDeep, isNil, isObject, isPlainObject, omit, pick } from 'lodash-es';
6
- import { v4 as uuid } from 'uuid';
6
+ import { v4 as uuid, validate as validateUUID } from 'uuid';
7
7
  import { parse as wktToGeoJSON } from 'wellknown';
8
8
  import { getHelpers } from '../database/helpers/index.js';
9
9
  import getDatabase from '../database/index.js';
@@ -44,7 +44,10 @@ export class PayloadService {
44
44
  if (action === 'create' && !value) {
45
45
  return uuid();
46
46
  }
47
- return value;
47
+ if (action === 'create' && !validateUUID(String(value))) {
48
+ throw new InvalidPayloadException(`Invalid UUID "${value}"`);
49
+ }
50
+ return action === 'create' ? String(value).toLowerCase() : value;
48
51
  },
49
52
  async 'cast-boolean'({ action, value }) {
50
53
  if (action === 'read') {
@@ -5,6 +5,7 @@ import { cloneDeep, isEmpty } from 'lodash-es';
5
5
  import { performance } from 'perf_hooks';
6
6
  import getDatabase from '../database/index.js';
7
7
  import env from '../env.js';
8
+ import logger from '../logger.js';
8
9
  import { RecordNotUniqueException } from '../exceptions/database/record-not-unique.js';
9
10
  import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions/index.js';
10
11
  import isUrlAllowed from '../utils/is-url-allowed.js';
@@ -340,37 +341,42 @@ export class UsersService extends ItemsService {
340
341
  async requestPasswordReset(email, url, subject) {
341
342
  const STALL_TIME = 500;
342
343
  const timeStart = performance.now();
343
- const user = await this.getUserByEmail(email);
344
- if (user?.status !== 'active') {
345
- await stall(STALL_TIME, timeStart);
346
- throw new ForbiddenException();
344
+ try {
345
+ if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
346
+ throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
347
+ }
348
+ const user = await this.getUserByEmail(email);
349
+ if (user?.status !== 'active') {
350
+ throw new ForbiddenException();
351
+ }
352
+ const mailService = new MailService({
353
+ schema: this.schema,
354
+ knex: this.knex,
355
+ accountability: this.accountability,
356
+ });
357
+ const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
358
+ const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
359
+ const acceptURL = url
360
+ ? new Url(url).setQuery('token', token).toString()
361
+ : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
362
+ const subjectLine = subject ? subject : 'Password Reset Request';
363
+ mailService
364
+ .send({
365
+ to: user.email,
366
+ subject: subjectLine,
367
+ template: {
368
+ name: 'password-reset',
369
+ data: {
370
+ url: acceptURL,
371
+ email: user.email,
372
+ },
373
+ },
374
+ })
375
+ .catch((err) => logger.warn(err, `[email] ${err}`));
347
376
  }
348
- if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
349
- throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
377
+ finally {
378
+ await stall(STALL_TIME, timeStart);
350
379
  }
351
- const mailService = new MailService({
352
- schema: this.schema,
353
- knex: this.knex,
354
- accountability: this.accountability,
355
- });
356
- const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
357
- const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
358
- const acceptURL = url
359
- ? new Url(url).setQuery('token', token).toString()
360
- : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
361
- const subjectLine = subject ? subject : 'Password Reset Request';
362
- await mailService.send({
363
- to: user.email,
364
- subject: subjectLine,
365
- template: {
366
- name: 'password-reset',
367
- data: {
368
- url: acceptURL,
369
- email: user.email,
370
- },
371
- },
372
- });
373
- await stall(STALL_TIME, timeStart);
374
380
  }
375
381
  async resetPassword(token, password) {
376
382
  const { email, scope, hash } = jwt.verify(token, env['SECRET'], { issuer: 'directus' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.6.1",
3
+ "version": "12.7.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -144,20 +144,20 @@
144
144
  "tmp-promise": "3.0.3",
145
145
  "tsx": "3.12.6",
146
146
  "uuid-validate": "0.0.3",
147
- "@wbce-d9/app": "11.1.0",
148
- "@wbce-d9/constants": "10.0.0",
149
- "@wbce-d9/exceptions": "10.0.1",
147
+ "@wbce-d9/app": "11.2.0",
148
+ "@wbce-d9/extensions-sdk": "10.0.5",
150
149
  "@wbce-d9/schema": "10.0.0",
151
- "@wbce-d9/storage": "10.0.0",
152
150
  "@wbce-d9/specs": "9.26.5",
153
151
  "@wbce-d9/storage-driver-azure": "10.0.4",
154
152
  "@wbce-d9/storage-driver-cloudinary": "10.0.5",
155
- "@wbce-d9/extensions-sdk": "10.0.5",
156
153
  "@wbce-d9/storage-driver-local": "10.0.4",
157
154
  "@wbce-d9/storage-driver-gcs": "10.0.4",
158
155
  "@wbce-d9/storage-driver-s3": "10.0.4",
159
156
  "@wbce-d9/update-check": "10.0.4",
160
- "@wbce-d9/utils": "10.0.4"
157
+ "@wbce-d9/storage": "10.0.0",
158
+ "@wbce-d9/utils": "10.0.4",
159
+ "@wbce-d9/constants": "10.0.0",
160
+ "@wbce-d9/exceptions": "10.0.1"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@ngneat/falso": "6.4.0",