@wbce-d9/api 12.2.0 → 12.4.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.
@@ -1,4 +1,4 @@
1
- import type { Client, TokenSet } from 'openid-client';
1
+ import * as oidc from 'openid-client';
2
2
  import { UsersService } from '../../services/users.js';
3
3
  import type { User } from '../../types/index.js';
4
4
  import { LocalAuthDriver } from './local.js';
@@ -12,12 +12,12 @@ export interface UserPayload {
12
12
  auth_data: any;
13
13
  }
14
14
  export declare abstract class BaseOAuthDriver extends LocalAuthDriver {
15
- abstract getClient(): Promise<Client>;
15
+ abstract getClient(): Promise<oidc.Configuration>;
16
16
  abstract getredirectUrl(): string;
17
17
  abstract getUserService(): UsersService;
18
18
  abstract getConfig(): Record<string, any>;
19
19
  abstract getClientName(): string;
20
- abstract getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[TokenSet, Record<string, unknown>, UserPayload]>;
20
+ abstract getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers, Record<string, unknown>, UserPayload]>;
21
21
  generateCodeVerifier(): string;
22
22
  fetchUserId(identifier: string): Promise<string | undefined>;
23
23
  getUserID(payload: Record<string, any>): Promise<string>;
@@ -1,5 +1,5 @@
1
1
  import { parseJSON } from '@wbce-d9/utils';
2
- import { errors, generators } from 'openid-client';
2
+ import * as oidc from 'openid-client';
3
3
  import getDatabase from '../../database/index.js';
4
4
  import emitter from '../../emitter.js';
5
5
  import { RecordNotUniqueException } from '../../exceptions/database/record-not-unique.js';
@@ -9,7 +9,7 @@ import { UsersService } from '../../services/users.js';
9
9
  import { LocalAuthDriver } from './local.js';
10
10
  export class BaseOAuthDriver extends LocalAuthDriver {
11
11
  generateCodeVerifier() {
12
- return generators.codeVerifier();
12
+ return oidc.randomPKCECodeVerifier();
13
13
  }
14
14
  async fetchUserId(identifier) {
15
15
  const user = await this.knex.select('id').from('directus_users').where('external_identifier', identifier).first();
@@ -42,7 +42,7 @@ export class BaseOAuthDriver extends LocalAuthDriver {
42
42
  // Update user to update refresh_token and other properties that might have changed
43
43
  await this.getUserService().updateOne(userId, {
44
44
  ...updatedUserPayload,
45
- auth_data: userPayload.auth_data,
45
+ auth_data: userPayload.auth_data ?? null,
46
46
  });
47
47
  return userId;
48
48
  }
@@ -86,9 +86,9 @@ export class BaseOAuthDriver extends LocalAuthDriver {
86
86
  if (authData?.['refreshToken']) {
87
87
  try {
88
88
  const client = await this.getClient();
89
- const tokenSet = await client.refresh(authData['refreshToken']);
89
+ const tokenSet = await oidc.refreshTokenGrant(client, authData['refreshToken']);
90
90
  if (tokenSet.refresh_token) {
91
- await client.revoke(tokenSet.refresh_token, 'refresh_token');
91
+ await oidc.tokenRevocation(client, tokenSet.refresh_token);
92
92
  }
93
93
  await this.getUserService().updateOne(user.id, {
94
94
  auth_data: null,
@@ -119,7 +119,7 @@ export class BaseOAuthDriver extends LocalAuthDriver {
119
119
  if (authData?.['refreshToken']) {
120
120
  try {
121
121
  const client = await this.getClient();
122
- const tokenSet = await client.refresh(authData['refreshToken']);
122
+ const tokenSet = await oidc.refreshTokenGrant(client, authData['refreshToken']);
123
123
  // Update user refreshToken if provided
124
124
  if (tokenSet.refresh_token) {
125
125
  await this.getUserService().updateOne(user.id, {
@@ -138,7 +138,7 @@ export class BaseOAuthDriver extends LocalAuthDriver {
138
138
  }
139
139
  }
140
140
  handleError = (e) => {
141
- if (e instanceof errors.OPError) {
141
+ if (e instanceof oidc.ResponseBodyError) {
142
142
  if (e.error === 'invalid_grant') {
143
143
  // Invalid token
144
144
  logger.trace(e, `[${this.getClientName()}] Invalid grant`);
@@ -151,7 +151,7 @@ export class BaseOAuthDriver extends LocalAuthDriver {
151
151
  message: e.error_description,
152
152
  });
153
153
  }
154
- else if (e instanceof errors.RPError) {
154
+ else if (e instanceof oidc.ClientError) {
155
155
  // Internal client error
156
156
  logger.trace(e, `[${this.getClientName()}] Unknown RP error`);
157
157
  return new InvalidCredentialsException();
@@ -1,20 +1,20 @@
1
1
  import { Router } from 'express';
2
- import type { BaseClient, Client, TokenSet } from 'openid-client';
2
+ import * as oidc from 'openid-client';
3
3
  import { UsersService } from '../../services/users.js';
4
4
  import type { AuthDriverOptions } from '../../types/index.js';
5
5
  import { BaseOAuthDriver, type UserPayload } from './baseoauth.js';
6
6
  export declare class OAuth2AuthDriver extends BaseOAuthDriver {
7
- client: Client;
7
+ client: oidc.Configuration;
8
8
  redirectUrl: string;
9
9
  usersService: UsersService;
10
10
  config: Record<string, any>;
11
- getClient(): Promise<BaseClient>;
11
+ getClient(): Promise<oidc.Configuration>;
12
12
  getredirectUrl(): string;
13
13
  getUserService(): UsersService;
14
14
  getConfig(): Record<string, any>;
15
15
  getClientName(): string;
16
16
  constructor(options: AuthDriverOptions, config: Record<string, any>);
17
- generateAuthUrl(codeVerifier: string, prompt?: boolean): string;
18
- getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[TokenSet, Record<string, unknown>, UserPayload]>;
17
+ generateAuthUrl(codeVerifier: string, prompt?: boolean): Promise<string>;
18
+ getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers, Record<string, unknown>, UserPayload]>;
19
19
  }
20
20
  export declare function createOAuth2AuthRouter(providerName: string): Router;
@@ -2,9 +2,9 @@ import { BaseException } from '@wbce-d9/exceptions';
2
2
  import express, { Router } from 'express';
3
3
  import flatten from 'flat';
4
4
  import jwt from 'jsonwebtoken';
5
- import { Issuer, generators } from 'openid-client';
5
+ import * as oidc from 'openid-client';
6
6
  import { getAuthProvider } from '../../auth.js';
7
- import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
7
+ import { ACCESS_COOKIE_OPTIONS, OAUTH2_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
8
8
  import env from '../../env.js';
9
9
  import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException, InvalidPayloadException, } from '../../exceptions/index.js';
10
10
  import logger from '../../logger.js';
@@ -47,35 +47,31 @@ export class OAuth2AuthDriver extends BaseOAuthDriver {
47
47
  this.redirectUrl = redirectUrl.toString();
48
48
  this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
49
49
  this.config = additionalConfig;
50
- const issuer = new Issuer({
50
+ const clientOptionsOverrides = getConfigFromEnv(`AUTH_${config['provider'].toUpperCase()}_CLIENT_`, [`AUTH_${config['provider'].toUpperCase()}_CLIENT_ID`, `AUTH_${config['provider'].toUpperCase()}_CLIENT_SECRET`], 'underscore');
51
+ this.client = new oidc.Configuration({
51
52
  authorization_endpoint: authorizeUrl,
52
53
  token_endpoint: accessUrl,
53
54
  userinfo_endpoint: profileUrl,
54
55
  issuer: additionalConfig['provider'],
55
- });
56
- const clientOptionsOverrides = getConfigFromEnv(`AUTH_${config['provider'].toUpperCase()}_CLIENT_`, [`AUTH_${config['provider'].toUpperCase()}_CLIENT_ID`, `AUTH_${config['provider'].toUpperCase()}_CLIENT_SECRET`], 'underscore');
57
- this.client = new issuer.Client({
58
- client_id: clientId,
59
- client_secret: clientSecret,
60
- redirect_uris: [this.redirectUrl],
61
- response_types: ['code'],
62
- ...clientOptionsOverrides,
63
- });
56
+ }, clientId, { client_secret: clientSecret, ...clientOptionsOverrides });
64
57
  }
65
- generateAuthUrl(codeVerifier, prompt = false) {
58
+ async generateAuthUrl(codeVerifier, prompt = false) {
66
59
  try {
67
- const codeChallenge = generators.codeChallenge(codeVerifier);
60
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
68
61
  const paramsConfig = typeof this.config['params'] === 'object' ? this.config['params'] : {};
69
- return this.client.authorizationUrl({
62
+ const params = {
70
63
  scope: this.config['scope'] ?? 'email',
71
64
  access_type: 'offline',
72
- prompt: prompt ? 'consent' : undefined,
73
65
  ...paramsConfig,
74
66
  code_challenge: codeChallenge,
75
67
  code_challenge_method: 'S256',
76
68
  // Some providers require state even with PKCE
77
69
  state: codeChallenge,
78
- });
70
+ };
71
+ if (prompt) {
72
+ params['prompt'] = 'consent';
73
+ }
74
+ return oidc.buildAuthorizationUrl(this.client, params).href;
79
75
  }
80
76
  catch (e) {
81
77
  throw this.handleError(e);
@@ -85,8 +81,13 @@ export class OAuth2AuthDriver extends BaseOAuthDriver {
85
81
  let tokenSet;
86
82
  let userInfo;
87
83
  try {
88
- tokenSet = await this.client.oauthCallback(this.redirectUrl, { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: generators.codeChallenge(payload['codeVerifier']) });
89
- userInfo = await this.client.userinfo(tokenSet.access_token);
84
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(payload['codeVerifier']);
85
+ const callbackUrl = new URL(payload['callbackPath'], this.redirectUrl);
86
+ tokenSet = await oidc.authorizationCodeGrant(this.client, callbackUrl, {
87
+ pkceCodeVerifier: payload['codeVerifier'],
88
+ expectedState: codeChallenge,
89
+ });
90
+ userInfo = await oidc.fetchUserInfo(this.client, tokenSet.access_token, oidc.skipSubjectCheck);
90
91
  }
91
92
  catch (e) {
92
93
  throw this.handleError(e);
@@ -115,7 +116,7 @@ export class OAuth2AuthDriver extends BaseOAuthDriver {
115
116
  }
116
117
  export function createOAuth2AuthRouter(providerName) {
117
118
  const router = Router();
118
- router.get('/', (req, res) => {
119
+ router.get('/', asyncHandler(async (req, res) => {
119
120
  const provider = getAuthProvider(providerName);
120
121
  const codeVerifier = provider.generateCodeVerifier();
121
122
  const prompt = !!req.query['prompt'];
@@ -124,15 +125,12 @@ export function createOAuth2AuthRouter(providerName) {
124
125
  throw new InvalidPayloadException(`URL "${redirect}" can't be used to redirect after login`);
125
126
  }
126
127
  const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
127
- expiresIn: '5m',
128
+ expiresIn: Math.floor(OAUTH2_COOKIE_OPTIONS.maxAge / 1000),
128
129
  issuer: 'directus',
129
130
  });
130
- res.cookie(`oauth2.${providerName}`, token, {
131
- httpOnly: true,
132
- sameSite: 'lax',
133
- });
134
- return res.redirect(provider.generateAuthUrl(codeVerifier, prompt));
135
- }, respond);
131
+ res.cookie(`oauth2.${providerName}`, token, OAUTH2_COOKIE_OPTIONS);
132
+ return res.redirect(await provider.generateAuthUrl(codeVerifier, prompt));
133
+ }), respond);
136
134
  router.post('/callback', express.urlencoded({ extended: false }), (req, res) => {
137
135
  res.redirect(303, `./callback?${new URLSearchParams(req.body)}`);
138
136
  }, respond);
@@ -164,8 +162,8 @@ export function createOAuth2AuthRouter(providerName) {
164
162
  });
165
163
  let authResponse;
166
164
  try {
167
- res.clearCookie(`oauth2.${providerName}`);
168
165
  authResponse = await authenticationService.login(providerName, {
166
+ callbackPath: req.originalUrl,
169
167
  code: req.query['code'],
170
168
  codeVerifier: verifier,
171
169
  state: req.query['state'],
@@ -1,20 +1,20 @@
1
1
  import { Router } from 'express';
2
- import type { AuthorizationParameters, BaseClient, Client, TokenSet } from 'openid-client';
2
+ import * as oidc from 'openid-client';
3
3
  import { UsersService } from '../../services/users.js';
4
4
  import type { AuthDriverOptions } from '../../types/index.js';
5
5
  import { BaseOAuthDriver, type UserPayload } from './baseoauth.js';
6
6
  export declare class OpenIDAuthDriver extends BaseOAuthDriver {
7
- client: Promise<Client>;
7
+ client: Promise<oidc.Configuration>;
8
8
  redirectUrl: string;
9
9
  usersService: UsersService;
10
10
  config: Record<string, any>;
11
- getClient(): Promise<BaseClient>;
11
+ getClient(): Promise<oidc.Configuration>;
12
12
  getredirectUrl(): string;
13
13
  getUserService(): UsersService;
14
14
  getConfig(): Record<string, any>;
15
15
  getClientName(): string;
16
16
  constructor(options: AuthDriverOptions, config: Record<string, any>);
17
- generateAuthUrl(codeVerifier: string, prompt?: boolean, additionalParams?: AuthorizationParameters | undefined): Promise<string>;
18
- getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[TokenSet, Record<string, unknown>, UserPayload]>;
17
+ generateAuthUrl(codeVerifier: string, prompt?: boolean, additionalParams?: Record<string, string>): Promise<string>;
18
+ getTokenSetAndUserInfo(payload: Record<string, any>): Promise<[oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers, Record<string, unknown>, UserPayload]>;
19
19
  }
20
20
  export declare function createOpenIDAuthRouter(providerName: string): Router;
@@ -2,9 +2,9 @@ import { BaseException } from '@wbce-d9/exceptions';
2
2
  import express, { Router } from 'express';
3
3
  import flatten from 'flat';
4
4
  import jwt from 'jsonwebtoken';
5
- import { Issuer, generators } from 'openid-client';
5
+ import * as oidc from 'openid-client';
6
6
  import { getAuthProvider } from '../../auth.js';
7
- import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
7
+ import { ACCESS_COOKIE_OPTIONS, OAUTH2_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
8
8
  import env from '../../env.js';
9
9
  import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException, InvalidPayloadException, } from '../../exceptions/index.js';
10
10
  import logger from '../../logger.js';
@@ -48,55 +48,48 @@ export class OpenIDAuthDriver extends BaseOAuthDriver {
48
48
  this.redirectUrl = redirectUrl.toString();
49
49
  this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
50
50
  this.config = additionalConfig;
51
- this.client = new Promise((resolve, reject) => {
52
- Issuer.discover(issuerUrl)
53
- .then((issuer) => {
54
- const supportedTypes = issuer.metadata['response_types_supported'];
55
- if (!supportedTypes?.includes('code')) {
56
- reject(new InvalidConfigException('OpenID provider does not support required code flow', {
57
- provider: additionalConfig['provider'],
58
- }));
59
- }
60
- resolve(new issuer.Client({
61
- client_id: clientId,
51
+ this.client = (async () => {
52
+ let config;
53
+ try {
54
+ config = await oidc.discovery(new URL(issuerUrl), clientId, {
62
55
  client_secret: clientSecret,
63
- redirect_uris: [this.redirectUrl],
64
- response_types: ['code'],
65
56
  ...clientOptionsOverrides,
66
- }));
67
- })
68
- .catch((e) => {
57
+ });
58
+ }
59
+ catch (e) {
69
60
  logger.error(e, '[OpenID] Failed to fetch provider config');
70
61
  process.exit(1);
71
- });
72
- });
62
+ }
63
+ const supportedTypes = config.serverMetadata().response_types_supported;
64
+ if (supportedTypes && !supportedTypes.includes('code')) {
65
+ throw new InvalidConfigException('OpenID provider does not support required code flow', {
66
+ provider: additionalConfig['provider'],
67
+ });
68
+ }
69
+ return config;
70
+ })();
73
71
  }
74
72
  async generateAuthUrl(codeVerifier, prompt = false, additionalParams) {
75
73
  try {
76
74
  const client = await this.client;
77
- const codeChallenge = generators.codeChallenge(codeVerifier);
75
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
78
76
  const paramsConfig = typeof this.config['params'] === 'object' ? this.config['params'] : {};
79
- const redirectAfterLogin = additionalParams?.['redirect'];
80
- delete additionalParams?.['redirect'];
81
- let finalRedirectUri = this.redirectUrl;
82
- if (redirectAfterLogin) {
83
- const redirectUriWithParams = new URL(this.redirectUrl);
84
- redirectUriWithParams.searchParams.set('redirect', redirectAfterLogin);
85
- finalRedirectUri = redirectUriWithParams.toString();
86
- }
87
- return client.authorizationUrl({
77
+ const params = {
88
78
  scope: this.config['scope'] ?? 'openid profile email',
89
79
  access_type: 'offline',
90
- prompt: prompt ? 'consent' : undefined,
91
80
  ...paramsConfig,
92
81
  code_challenge: codeChallenge,
93
82
  code_challenge_method: 'S256',
94
83
  // Some providers require state even with PKCE
95
84
  state: codeChallenge,
96
85
  nonce: codeChallenge,
97
- redirect_uri: finalRedirectUri,
86
+ redirect_uri: this.redirectUrl,
98
87
  ...additionalParams,
99
- });
88
+ };
89
+ if (prompt) {
90
+ params.prompt = 'consent';
91
+ }
92
+ return oidc.buildAuthorizationUrl(client, params).href;
100
93
  }
101
94
  catch (e) {
102
95
  throw this.handleError(e);
@@ -107,19 +100,18 @@ export class OpenIDAuthDriver extends BaseOAuthDriver {
107
100
  let userInfo;
108
101
  try {
109
102
  const client = await this.client;
110
- const codeChallenge = generators.codeChallenge(payload['codeVerifier']);
111
- let finalRedirectUri = this.redirectUrl;
112
- if (payload['redirect']) {
113
- const redirectUriWithParams = new URL(this.redirectUrl);
114
- redirectUriWithParams.searchParams.set('redirect', payload['redirect']);
115
- finalRedirectUri = redirectUriWithParams.toString();
116
- }
117
- tokenSet = await client.callback(finalRedirectUri, { code: payload['code'], state: payload['state'], iss: payload['iss'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge, nonce: codeChallenge });
118
- userInfo = tokenSet.claims();
119
- if (client.issuer.metadata['userinfo_endpoint']) {
103
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(payload['codeVerifier']);
104
+ const callbackUrl = new URL(payload['callbackPath'], this.redirectUrl);
105
+ tokenSet = await oidc.authorizationCodeGrant(client, callbackUrl, {
106
+ pkceCodeVerifier: payload['codeVerifier'],
107
+ expectedState: codeChallenge,
108
+ expectedNonce: codeChallenge,
109
+ });
110
+ userInfo = (tokenSet.claims() ?? {});
111
+ if (client.serverMetadata().userinfo_endpoint) {
120
112
  userInfo = {
121
113
  ...userInfo,
122
- ...(await client.userinfo(tokenSet.access_token)),
114
+ ...(await oidc.fetchUserInfo(client, tokenSet.access_token, tokenSet.claims()?.sub ?? oidc.skipSubjectCheck)),
123
115
  };
124
116
  }
125
117
  }
@@ -159,19 +151,17 @@ export function createOpenIDAuthRouter(providerName) {
159
151
  throw new InvalidPayloadException(`URL "${redirect}" can't be used to redirect after login`);
160
152
  }
161
153
  const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
162
- expiresIn: '5m',
154
+ expiresIn: Math.floor(OAUTH2_COOKIE_OPTIONS.maxAge / 1000),
163
155
  issuer: 'directus',
164
156
  });
165
- const additionalParams = { ...req.query };
166
- delete additionalParams['prompt'];
157
+ const additionalParams = Object.fromEntries(Object.entries(req.query)
158
+ .filter(([k, v]) => k !== 'prompt' && k !== 'redirect' && typeof v === 'string')
159
+ .map(([k, v]) => [k, v]));
167
160
  const authUrl = await provider.generateAuthUrl(codeVerifier, prompt, additionalParams);
168
161
  const urlParams = new URL(authUrl);
169
162
  const state = urlParams.searchParams.get('state');
170
163
  const cookieName = `openid.${providerName}.${state || ''}`;
171
- res.cookie(cookieName, token, {
172
- httpOnly: true,
173
- sameSite: 'lax',
174
- });
164
+ res.cookie(cookieName, token, OAUTH2_COOKIE_OPTIONS);
175
165
  return res.redirect(authUrl);
176
166
  }), respond);
177
167
  router.post('/callback', express.urlencoded({ extended: false }), (req, res) => {
@@ -214,13 +204,13 @@ export function createOpenIDAuthRouter(providerName) {
214
204
  });
215
205
  let authResponse;
216
206
  try {
217
- res.clearCookie(cookieName);
218
207
  authResponse = await authenticationService.login(providerName, {
219
208
  code: req.query['code'],
220
209
  codeVerifier: verifier,
221
210
  state: req.query['state'],
222
211
  iss: req.query['iss'],
223
212
  redirect: validRedirectUrl,
213
+ callbackPath: req.originalUrl,
224
214
  });
225
215
  }
226
216
  catch (error) {
@@ -307,8 +307,8 @@ EXTENSIONS_AUTO_RELOAD=false
307
307
  ####################################################################################################
308
308
  ### Email
309
309
 
310
- # Email address from which emails are sent ["no-reply@directus.io"]
311
- EMAIL_FROM="no-reply@directus.io"
310
+ # Email address from which emails are sent
311
+ EMAIL_FROM="no-reply@example.com"
312
312
 
313
313
  # What to use to send emails. One of
314
314
  # sendmail, smtp, mailgun, sendgrid, ses.
@@ -13,6 +13,8 @@ export declare const REFRESH_COOKIE_CLEAR_OPTIONS: CookieOptions;
13
13
  export declare const GET_SET_HEADER: (cookieValue: string) => string;
14
14
  export declare const ACCESS_COOKIE_OPTIONS: CookieOptions;
15
15
  export declare const ACCESS_COOKIE_CLEAR_OPTIONS: CookieOptions;
16
+ export declare const OAUTH2_COOKIE_OPTIONS: CookieOptions;
17
+ export declare const OAUTH2_COOKIE_CLEAR_OPTIONS: CookieOptions;
16
18
  export declare const OAS_REQUIRED_SCHEMAS: string[];
17
19
  /** Formats from which transformation is supported */
18
20
  export declare const SUPPORTED_IMAGE_TRANSFORM_FORMATS: string[];
package/dist/constants.js CHANGED
@@ -82,6 +82,20 @@ export const ACCESS_COOKIE_CLEAR_OPTIONS = (() => {
82
82
  delete rest.expires;
83
83
  return rest;
84
84
  })();
85
+ export const OAUTH2_COOKIE_OPTIONS = {
86
+ httpOnly: true,
87
+ domain: env['OAUTH2_TOKEN_COOKIE_DOMAIN'] || env['ACCESS_TOKEN_COOKIE_DOMAIN'],
88
+ maxAge: getMilliseconds(env['OAUTH2_TOKEN_TTL']),
89
+ secure: (env['OAUTH2_TOKEN_COOKIE_SECURE'] || env['ACCESS_TOKEN_COOKIE_SECURE']) ?? false,
90
+ sameSite: env['OAUTH2_TOKEN_COOKIE_SAME_SITE'] || 'lax',
91
+ path: '/auth',
92
+ };
93
+ export const OAUTH2_COOKIE_CLEAR_OPTIONS = (() => {
94
+ const { ...rest } = ACCESS_COOKIE_OPTIONS;
95
+ delete rest.maxAge;
96
+ delete rest.expires;
97
+ return rest;
98
+ })();
85
99
  export const OAS_REQUIRED_SCHEMAS = ['Diff', 'Schema', 'Query', 'x-metadata'];
86
100
  /** Formats from which transformation is supported */
87
101
  export const SUPPORTED_IMAGE_TRANSFORM_FORMATS = ['image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/avif'];
@@ -7,7 +7,7 @@ columns:
7
7
  type: string
8
8
  length: 100
9
9
  nullable: false
10
- default: 'Directus'
10
+ default: 'd9'
11
11
  project_url:
12
12
  type: string
13
13
  length: 255
@@ -63,6 +63,8 @@ fields:
63
63
  placeholder: $t:field_options.directus_files.filename_disk
64
64
  readonly: true
65
65
  width: half
66
+ special:
67
+ - system-only
66
68
 
67
69
  - field: filename_download
68
70
  interface: input
@@ -180,3 +180,6 @@ fields:
180
180
 
181
181
  - field: auth_data
182
182
  hidden: true
183
+ special:
184
+ - system-only
185
+ - conceal
package/dist/env.js CHANGED
@@ -49,6 +49,10 @@ const allowedEnvironmentVars = [
49
49
  'REFRESH_TOKEN_COOKIE_SAME_SITE',
50
50
  'REFRESH_TOKEN_COOKIE_NAME',
51
51
  'REFRESH_TOKEN_OVERLAP_DURATION',
52
+ 'OAUTH2_TOKEN_TTL',
53
+ 'OAUTH2_TOKEN_COOKIE_DOMAIN',
54
+ 'OAUTH2_TOKEN_COOKIE_SECURE',
55
+ 'OAUTH2_TOKEN_COOKIE_SAME_SITE',
52
56
  'LOGIN_STALL_TIME',
53
57
  'PASSWORD_RESET_URL_ALLOW_LIST',
54
58
  'USER_INVITE_URL_ALLOW_LIST',
@@ -235,6 +239,7 @@ const defaults = {
235
239
  REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
236
240
  REFRESH_TOKEN_COOKIE_NAME: 'directus_refresh_token',
237
241
  REFRESH_TOKEN_OVERLAP_DURATION: '10s',
242
+ OAUTH2_TOKEN_TTL: '1d',
238
243
  LOGIN_STALL_TIME: 500,
239
244
  SERVER_SHUTDOWN_TIMEOUT: 1000,
240
245
  ROOT_REDIRECT: './admin',
@@ -7,7 +7,7 @@ export class ForbiddenException extends BaseException {
7
7
  * We currently don't show the reason for a forbidden exception in the API output, as that
8
8
  * has the potential to leak schema information (eg a "No permission" vs "No permission to files"
9
9
  * would leak that a thing called "files" exists.
10
- * Ref https://github.com/LaWebcapsule/directus9/discussions/4368
10
+ * Ref https://github.com/LaWebcapsule/d9/discussions/4368
11
11
  */
12
12
  }
13
13
  }
@@ -51,7 +51,7 @@ export const handler = async (req, _res, next) => {
51
51
  })
52
52
  .from('directus_sessions AS s')
53
53
  .leftJoin('directus_users AS u', 's.user', 'u.id')
54
- .where('s.token', payload.refresh_token)
54
+ .where('s.session_id', payload.session_id)
55
55
  .andWhere('s.expires', '>=', new Date())
56
56
  .first();
57
57
  if (!user) {
@@ -3,7 +3,7 @@ import env from '../env.js';
3
3
  let corsMiddleware = (_req, _res, next) => next();
4
4
  if (env['CORS_ENABLED'] === true) {
5
5
  corsMiddleware = cors({
6
- origin: env['CORS_ORIGIN'] || true,
6
+ origin: env['CORS_ORIGIN'] || false,
7
7
  methods: env['CORS_METHODS'] || 'GET,POST,PATCH,DELETE',
8
8
  allowedHeaders: env['CORS_ALLOWED_HEADERS'],
9
9
  exposedHeaders: env['CORS_EXPOSED_HEADERS'],
@@ -135,7 +135,7 @@ export class AuthenticationService {
135
135
  role: user.role,
136
136
  app_access: user.app_access,
137
137
  admin_access: user.admin_access,
138
- refresh_token: refreshToken,
138
+ session_id: sessionId,
139
139
  };
140
140
  const customClaims = await emitter.emitFilter('auth.jwt', tokenPayload, {
141
141
  status: 'pending',
@@ -309,7 +309,7 @@ export class AuthenticationService {
309
309
  role: record.role_id,
310
310
  app_access: record.role_app_access,
311
311
  admin_access: record.role_admin_access,
312
- refresh_token: newRefreshToken,
312
+ session_id: sessionId,
313
313
  };
314
314
  if (record.share_id) {
315
315
  tokenPayload.share = record.share_id;
@@ -449,7 +449,13 @@ export class AuthorizationService {
449
449
  const originalItems = await mainItemService.readMany(keys);
450
450
  for (let i = 0; i < originalItems.length; i++) {
451
451
  const originalItem = originalItems[i];
452
- const emptyItemsToAdd = originalItem[fieldKey].length - field.update?.length - field.delete?.length;
452
+ const alreadyExistingItems = originalItem[fieldKey].length;
453
+ const updatedItemsIds = field.update.map((f) => f.id);
454
+ const deletedItemsIds = field.delete.map((f) => f?.id || f);
455
+ const twoTimesCountedItems = originalItem[fieldKey].reduce((n, item) => {
456
+ return n + Number(updatedItemsIds.includes(item) || deletedItemsIds.includes(item));
457
+ }, 0);
458
+ const emptyItemsToAdd = alreadyExistingItems - twoTimesCountedItems;
453
459
  const finalArrayToValidate = arrayToValidate.concat(Array(emptyItemsToAdd).fill({}));
454
460
  // validate partial items until the last key
455
461
  if (i == keys.length - 1) {
@@ -72,7 +72,7 @@ export class MailService {
72
72
  .from('directus_settings')
73
73
  .first();
74
74
  return {
75
- projectName: projectInfo?.project_name || 'Directus',
75
+ projectName: projectInfo?.project_name || 'd9',
76
76
  projectColor: projectInfo?.project_color || '#546e7a',
77
77
  projectLogo: getProjectLogoURL(projectInfo?.project_logo),
78
78
  projectUrl: projectInfo?.project_url || '',
@@ -105,6 +105,12 @@ export class PayloadService {
105
105
  return new Date(helpers.date.writeTimestamp(new Date().toISOString()));
106
106
  return value;
107
107
  },
108
+ async 'system-only'({ value, accountability }) {
109
+ if (accountability && (value === null || value)) {
110
+ throw new ForbiddenException();
111
+ }
112
+ return value;
113
+ },
108
114
  async 'cast-csv'({ action, value }) {
109
115
  if (Array.isArray(value) === false && typeof value !== 'string')
110
116
  return;
@@ -81,7 +81,7 @@ export class SharesService extends ItemsService {
81
81
  item: record.share_item,
82
82
  collection: record.share_collection,
83
83
  },
84
- refresh_token: refreshToken,
84
+ session_id: sessionId,
85
85
  };
86
86
  const accessToken = jwt.sign(tokenPayload, env['SECRET'], {
87
87
  expiresIn: env['ACCESS_TOKEN_TTL'],
@@ -31,7 +31,7 @@ export class TFAService {
31
31
  const project = await this.knex.select('project_name').from('directus_settings').limit(1).first();
32
32
  return {
33
33
  secret,
34
- url: authenticator.keyuri(user.email, project?.project_name || 'Directus', secret),
34
+ url: authenticator.keyuri(user.email, project?.project_name || 'd9', secret),
35
35
  };
36
36
  }
37
37
  async enableTFA(key, otp, secret) {
@@ -34,7 +34,7 @@ export type DirectusTokenPayload = {
34
34
  collection: string;
35
35
  item: string;
36
36
  };
37
- refresh_token: string;
37
+ session_id: string;
38
38
  };
39
39
  export type ShareData = {
40
40
  share_id: string;
@@ -1,3 +1,4 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
2
3
  export declare function compress(raw: Record<string, any> | Record<string, any>[]): Promise<Buffer>;
3
4
  export declare function decompress(compressed: Buffer): Promise<any>;
@@ -1,7 +1,7 @@
1
1
  import argon2 from 'argon2';
2
2
  import { getConfigFromEnv } from './get-config-from-env.js';
3
3
  export function generateHash(stringToHash) {
4
- const argon2HashConfigOptions = getConfigFromEnv('HASH_', 'HASH_RAW'); // Disallow the HASH_RAW option, see https://github.com/LaWebcapsule/directus9/discussions/7670#discussioncomment-1255805
4
+ const argon2HashConfigOptions = getConfigFromEnv('HASH_', 'HASH_RAW'); // Disallow the HASH_RAW option, see https://github.com/LaWebcapsule/d9/discussions/7670#discussioncomment-1255805
5
5
  // associatedData, if specified, must be passed as a Buffer to argon2.hash, see https://github.com/ranisalt/node-argon2/wiki/Options#associateddata
6
6
  'associatedData' in argon2HashConfigOptions &&
7
7
  (argon2HashConfigOptions['associatedData'] = Buffer.from(argon2HashConfigOptions['associatedData']));
@@ -13,5 +13,5 @@ export function getIPFromReq(req) {
13
13
  }
14
14
  }
15
15
  // IP addresses starting with ::ffff: are IPv4 addresses in IPv6 format. We can strip the prefix to get back to IPv4
16
- return ip.startsWith('::ffff:') ? ip.substring(7) : ip;
16
+ return ip?.startsWith('::ffff:') ? ip.substring(7) : ip ?? '';
17
17
  }
package/dist/utils/jwt.js CHANGED
@@ -21,9 +21,9 @@ export function verifyJWT(token, secret) {
21
21
  return payload;
22
22
  }
23
23
  export function verifyAccessJWT(token, secret) {
24
- const { id, role, app_access, admin_access, share, share_scope, refresh_token } = verifyJWT(token, secret);
25
- if (role === undefined || app_access === undefined || admin_access === undefined || refresh_token === undefined) {
24
+ const { id, role, app_access, admin_access, share, share_scope, session_id } = verifyJWT(token, secret);
25
+ if (role === undefined || app_access === undefined || admin_access === undefined || session_id === undefined) {
26
26
  throw new InvalidTokenException('Invalid token payload.');
27
27
  }
28
- return { id, role, app_access, admin_access, share, share_scope, refresh_token };
28
+ return { id, role, app_access, admin_access, share, share_scope, session_id };
29
29
  }
@@ -1,3 +1,4 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
2
3
  export declare function parseIptc(buffer: Buffer): Record<string, unknown>;
3
4
  export declare function parseXmp(buffer: Buffer): Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.2.0",
3
+ "version": "12.4.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": [
@@ -22,16 +22,16 @@
22
22
  "framework",
23
23
  "vue"
24
24
  ],
25
- "homepage": "https://directus.io",
25
+ "homepage": "https://github.com/LaWebcapsule/d9",
26
26
  "bugs": {
27
- "url": "https://github.com/LaWebcapsule/directus9/issues"
27
+ "url": "https://github.com/LaWebcapsule/d9/issues"
28
28
  },
29
29
  "repository": {
30
30
  "type": "git",
31
- "url": "https://github.com/LaWebcapsule/directus9.git",
31
+ "url": "https://github.com/LaWebcapsule/d9.git",
32
32
  "directory": "api"
33
33
  },
34
- "funding": "https://github.com/LaWebcapsule/directus9?sponsor=1",
34
+ "funding": "https://github.com/LaWebcapsule/d9?sponsor=1",
35
35
  "license": "GPL-3.0-only",
36
36
  "author": {
37
37
  "name": "Monospace Inc",
@@ -63,75 +63,77 @@
63
63
  "!**/__*__/"
64
64
  ],
65
65
  "dependencies": {
66
- "@authenio/samlify-node-xmllint": "2.0.0",
67
- "@aws-sdk/client-sesv2": "3.916.0",
68
- "@godaddy/terminus": "4.12.0",
69
- "@rollup/plugin-alias": "5.0.0",
70
66
  "@rollup/plugin-node-resolve": "15.0.2",
71
67
  "@rollup/plugin-virtual": "3.0.1",
72
68
  "@wbce-d9/format-title": "10.0.0",
73
- "argon2": "0.30.3",
74
- "async": "3.2.4",
75
69
  "axios": "^1.12.0",
70
+ "chalk": "5.2.0",
71
+ "commander": "10.0.1",
72
+ "date-fns": "2.29.3",
73
+ "execa": "7.1.1",
74
+ "express": "^4.21.2",
75
+ "fs-extra": "11.1.1",
76
+ "inquirer": "9.1.5",
77
+ "joi": "17.9.1",
78
+ "js-yaml": "4.1.1",
79
+ "json2csv": "5.0.7",
80
+ "knex": "3.1.0",
81
+ "lodash-es": "4.17.23",
82
+ "marked": "4.3.0",
83
+ "micromustache": "8.0.3",
84
+ "nanoid": "5.0.9",
85
+ "openapi3-ts": "3.2.0",
86
+ "ora": "6.3.0",
87
+ "pino": "8.11.0",
88
+ "rollup": "3.30.0",
89
+ "uuid": "9.0.0",
90
+ "wellknown": "0.5.0",
91
+ "@authenio/samlify-node-xmllint": "2.0.0",
92
+ "@aws-sdk/client-sesv2": "3.1004.0",
93
+ "@godaddy/terminus": "4.12.0",
94
+ "@rollup/plugin-alias": "5.0.0",
95
+ "argon2": "0.44.0",
96
+ "async": "3.2.4",
76
97
  "busboy": "1.6.0",
77
98
  "bytes": "3.1.2",
78
99
  "camelcase": "7.0.1",
79
- "chalk": "5.2.0",
80
100
  "chokidar": "3.5.3",
81
- "commander": "10.0.1",
82
101
  "content-disposition": "0.5.4",
83
102
  "cookie-parser": "1.4.7",
84
103
  "cors": "2.8.5",
85
104
  "csv-parser": "3.0.0",
86
- "date-fns": "2.29.3",
87
105
  "deep-diff": "1.0.2",
88
106
  "destroy": "1.2.0",
89
107
  "dotenv": "16.0.3",
90
108
  "encodeurl": "1.0.2",
91
109
  "eventemitter2": "6.4.9",
92
- "execa": "7.1.1",
93
110
  "exif-reader": "1.2.0",
94
- "express": "^4.21.2",
95
111
  "fast-redact": "^3.5.0",
96
112
  "flat": "5.0.2",
97
- "fs-extra": "11.1.1",
98
113
  "graphql": "16.8.1",
99
114
  "graphql-compose": "9.0.10",
100
115
  "helmet": "6.1.5",
101
116
  "icc": "3.0.0",
102
- "inquirer": "9.1.5",
103
117
  "ioredis": "5.3.2",
104
118
  "isolated-vm": "^6.0.1",
105
- "joi": "17.9.1",
106
- "js-yaml": "4.1.1",
107
119
  "js2xmlparser": "5.0.0",
108
- "json2csv": "5.0.7",
109
120
  "jsonwebtoken": "9.0.0",
110
121
  "keyv": "4.5.2",
111
- "knex": "3.1.0",
112
122
  "ldapjs": "2.3.3",
113
123
  "liquidjs": "10.7.0",
114
- "lodash-es": "4.17.23",
115
- "marked": "4.3.0",
116
- "micromustache": "8.0.3",
117
124
  "mime-types": "2.1.35",
118
125
  "ms": "2.1.3",
119
- "nanoid": "5.0.9",
120
126
  "node-cron": "3.0.2",
121
127
  "node-machine-id": "1.1.12",
122
128
  "nodemailer": "7.0.11",
123
129
  "object-hash": "3.0.0",
124
- "openapi3-ts": "3.2.0",
125
- "openid-client": "5.4.0",
126
- "ora": "6.3.0",
130
+ "openid-client": "6.8.2",
127
131
  "otplib": "12.0.1",
128
- "pino": "8.11.0",
129
132
  "pino-http": "8.3.3",
130
133
  "pino-http-print": "3.1.0",
131
134
  "pino-pretty": "10.0.0",
132
- "qs": "6.14.1",
135
+ "qs": "6.14.2",
133
136
  "rate-limiter-flexible": "2.4.1",
134
- "rollup": "3.29.5",
135
137
  "samlify": "2.10.0",
136
138
  "sanitize-html": "2.12.1",
137
139
  "sharp": "0.32.6",
@@ -140,26 +142,39 @@
140
142
  "strip-bom-stream": "5.0.0",
141
143
  "tmp-promise": "3.0.3",
142
144
  "tsx": "3.12.6",
143
- "uuid": "9.0.0",
144
145
  "uuid-validate": "0.0.3",
145
- "wellknown": "0.5.0",
146
146
  "@wbce-d9/constants": "10.0.0",
147
- "@wbce-d9/app": "9.35.1",
148
147
  "@wbce-d9/exceptions": "10.0.0",
149
148
  "@wbce-d9/extensions-sdk": "10.0.2",
149
+ "@wbce-d9/schema": "10.0.0",
150
150
  "@wbce-d9/specs": "9.26.5",
151
151
  "@wbce-d9/storage": "10.0.0",
152
+ "@wbce-d9/app": "10.0.0",
152
153
  "@wbce-d9/storage-driver-azure": "10.0.1",
153
- "@wbce-d9/schema": "10.0.0",
154
+ "@wbce-d9/storage-driver-gcs": "10.0.1",
154
155
  "@wbce-d9/storage-driver-cloudinary": "10.0.1",
155
156
  "@wbce-d9/storage-driver-local": "10.0.1",
156
157
  "@wbce-d9/storage-driver-s3": "10.0.1",
157
- "@wbce-d9/storage-driver-gcs": "10.0.1",
158
- "@wbce-d9/update-check": "10.0.1",
159
- "@wbce-d9/utils": "10.0.1"
158
+ "@wbce-d9/utils": "10.0.1",
159
+ "@wbce-d9/update-check": "10.0.1"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@ngneat/falso": "6.4.0",
163
+ "@types/fs-extra": "11.0.1",
164
+ "@types/inquirer": "9.0.3",
165
+ "@types/js-yaml": "4.0.5",
166
+ "@types/json2csv": "5.0.3",
167
+ "@types/lodash-es": "4.17.7",
168
+ "@types/marked": "4.0.8",
169
+ "@types/node": "22.15.29",
170
+ "@types/supertest": "2.0.12",
171
+ "@types/uuid": "9.0.1",
172
+ "@types/wellknown": "0.5.4",
173
+ "@vitest/coverage-v8": "4.1.0",
174
+ "@wbce-d9/tsconfig": "2.0.0",
175
+ "supertest": "6.3.3",
176
+ "typescript": "5.0.4",
177
+ "vitest": "4.1.0",
163
178
  "@types/async": "3.2.19",
164
179
  "@types/busboy": "1.5.0",
165
180
  "@types/bytes": "3.1.1",
@@ -170,40 +185,25 @@
170
185
  "@types/destroy": "1.0.0",
171
186
  "@types/encodeurl": "1.0.0",
172
187
  "@types/exif-reader": "1.0.0",
173
- "@types/express": "4.17.17",
174
- "@types/express-serve-static-core": "4.17.33",
188
+ "@types/express": "4.17.25",
189
+ "@types/express-serve-static-core": "4.19.8",
175
190
  "@types/fast-redact": "3.0.2",
176
191
  "@types/flat": "5.0.2",
177
- "@types/fs-extra": "11.0.1",
178
- "@types/inquirer": "9.0.3",
179
- "@types/js-yaml": "4.0.5",
180
- "@types/json2csv": "5.0.3",
181
192
  "@types/jsonwebtoken": "9.0.1",
182
193
  "@types/keyv": "4.2.0",
183
194
  "@types/ldapjs": "2.2.5",
184
- "@types/lodash-es": "4.17.7",
185
- "@types/marked": "4.0.8",
186
195
  "@types/mime-types": "2.1.1",
187
196
  "@types/ms": "0.7.31",
188
- "@types/node": "18.15.13",
189
197
  "@types/node-cron": "3.0.7",
190
198
  "@types/nodemailer": "6.4.7",
191
199
  "@types/object-hash": "3.0.2",
192
- "@types/qs": "6.9.7",
200
+ "@types/qs": "6.14.0",
193
201
  "@types/sanitize-html": "2.9.0",
194
202
  "@types/stream-json": "1.7.3",
195
- "@types/supertest": "2.0.12",
196
- "@types/uuid": "9.0.1",
197
203
  "@types/uuid-validate": "0.0.1",
198
- "@types/wellknown": "0.5.4",
199
- "@vitest/coverage-c8": "0.30.1",
200
- "@wbce-d9/tsconfig": "2.0.0",
201
204
  "copyfiles": "2.4.1",
202
205
  "form-data": "4.0.4",
203
206
  "knex-mock-client": "3.0.2",
204
- "supertest": "6.3.3",
205
- "typescript": "5.0.4",
206
- "vitest": "0.30.1",
207
207
  "@wbce-d9/types": "10.1.0"
208
208
  },
209
209
  "optionalDependencies": {