@wbce-d9/api 10.3.0 → 10.3.1

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,9 +1,10 @@
1
1
  import { Router } from 'express';
2
2
  import Joi from 'joi';
3
3
  import ldap from 'ldapjs';
4
- import { GET_SET_HEADER } from '../../constants.js';
4
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
5
5
  import getDatabase from '../../database/index.js';
6
6
  import emitter from '../../emitter.js';
7
+ import env from '../../env.js';
7
8
  import { RecordNotUniqueException } from '../../exceptions/database/record-not-unique.js';
8
9
  import { InvalidConfigException, InvalidCredentialsException, InvalidPayloadException, InvalidProviderException, ServiceUnavailableException, UnexpectedResponseException, } from '../../exceptions/index.js';
9
10
  import logger from '../../logger.js';
@@ -314,7 +315,8 @@ export function createLDAPAuthRouter(provider) {
314
315
  payload['data']['refresh_token'] = refreshToken;
315
316
  }
316
317
  if (mode === 'cookie') {
317
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
318
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
319
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
318
320
  }
319
321
  res.locals['payload'] = payload;
320
322
  return next();
@@ -2,7 +2,7 @@ import argon2 from 'argon2';
2
2
  import { Router } from 'express';
3
3
  import Joi from 'joi';
4
4
  import { performance } from 'perf_hooks';
5
- import { GET_SET_HEADER } from '../../constants.js';
5
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
6
6
  import env from '../../env.js';
7
7
  import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions/index.js';
8
8
  import { respond } from '../../middleware/respond.js';
@@ -74,7 +74,8 @@ export function createLocalAuthRouter(provider) {
74
74
  payload['data']['refresh_token'] = refreshToken;
75
75
  }
76
76
  if (mode === 'cookie') {
77
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
77
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
78
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
78
79
  }
79
80
  res.locals['payload'] = payload;
80
81
  return next();
@@ -2,11 +2,11 @@ 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 { generators, Issuer } from 'openid-client';
5
+ import { Issuer, generators } from 'openid-client';
6
6
  import { getAuthProvider } from '../../auth.js';
7
- import { GET_SET_HEADER } from '../../constants.js';
7
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
8
8
  import env from '../../env.js';
9
- import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException } from '../../exceptions/index.js';
9
+ import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException, InvalidPayloadException, } from '../../exceptions/index.js';
10
10
  import logger from '../../logger.js';
11
11
  import { respond } from '../../middleware/respond.js';
12
12
  import { AuthenticationService } from '../../services/authentication.js';
@@ -16,6 +16,7 @@ import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
16
16
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
17
17
  import { Url } from '../../utils/url.js';
18
18
  import { BaseOAuthDriver } from './baseoauth.js';
19
+ import { isRedirectAllowedOnLogin } from '../../utils/is-redirect-allowed-on-login.js';
19
20
  export class OAuth2AuthDriver extends BaseOAuthDriver {
20
21
  client;
21
22
  redirectUrl;
@@ -118,7 +119,11 @@ export function createOAuth2AuthRouter(providerName) {
118
119
  const provider = getAuthProvider(providerName);
119
120
  const codeVerifier = provider.generateCodeVerifier();
120
121
  const prompt = !!req.query['prompt'];
121
- const token = jwt.sign({ verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'], {
122
+ const redirect = req.query['redirect'];
123
+ if (isRedirectAllowedOnLogin(redirect, providerName) === false) {
124
+ throw new InvalidPayloadException(`URL "${redirect}" can't be used to redirect after login`);
125
+ }
126
+ const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
122
127
  expiresIn: '5m',
123
128
  issuer: 'directus',
124
129
  });
@@ -186,7 +191,8 @@ export function createOAuth2AuthRouter(providerName) {
186
191
  }
187
192
  const { accessToken, refreshToken, expires } = authResponse;
188
193
  if (redirect) {
189
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
194
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
195
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
190
196
  return res.redirect(redirect);
191
197
  }
192
198
  res.locals['payload'] = {
@@ -2,11 +2,11 @@ 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 { generators, Issuer } from 'openid-client';
5
+ import { Issuer, generators } from 'openid-client';
6
6
  import { getAuthProvider } from '../../auth.js';
7
- import { GET_SET_HEADER } from '../../constants.js';
7
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
8
8
  import env from '../../env.js';
9
- import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException } from '../../exceptions/index.js';
9
+ import { InvalidConfigException, InvalidCredentialsException, InvalidTokenException, InvalidPayloadException, } from '../../exceptions/index.js';
10
10
  import logger from '../../logger.js';
11
11
  import { respond } from '../../middleware/respond.js';
12
12
  import { AuthenticationService } from '../../services/authentication.js';
@@ -15,6 +15,7 @@ import asyncHandler from '../../utils/async-handler.js';
15
15
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
16
16
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
17
17
  import { Url } from '../../utils/url.js';
18
+ import { isRedirectAllowedOnLogin } from '../../utils/is-redirect-allowed-on-login.js';
18
19
  import { BaseOAuthDriver } from './baseoauth.js';
19
20
  export class OpenIDAuthDriver extends BaseOAuthDriver {
20
21
  client;
@@ -138,7 +139,11 @@ export function createOpenIDAuthRouter(providerName) {
138
139
  const provider = getAuthProvider(providerName);
139
140
  const codeVerifier = provider.generateCodeVerifier();
140
141
  const prompt = !!req.query['prompt'];
141
- const token = jwt.sign({ verifier: codeVerifier, redirect: req.query['redirect'], prompt }, env['SECRET'], {
142
+ const redirect = req.query['redirect'];
143
+ if (isRedirectAllowedOnLogin(redirect, providerName) === false) {
144
+ throw new InvalidPayloadException(`URL "${redirect}" can't be used to redirect after login`);
145
+ }
146
+ const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'], {
142
147
  expiresIn: '5m',
143
148
  issuer: 'directus',
144
149
  });
@@ -211,7 +216,8 @@ export function createOpenIDAuthRouter(providerName) {
211
216
  }
212
217
  const { accessToken, refreshToken, expires } = authResponse;
213
218
  if (redirect) {
214
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
219
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
220
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
215
221
  return res.redirect(redirect);
216
222
  }
217
223
  res.locals['payload'] = {
@@ -3,18 +3,19 @@ import { BaseException } from '@wbce-d9/exceptions';
3
3
  import express, { Router } from 'express';
4
4
  import * as samlify from 'samlify';
5
5
  import { getAuthProvider } from '../../auth.js';
6
- import { COOKIE_OPTIONS, GET_SET_HEADER } from '../../constants.js';
6
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
7
7
  import getDatabase from '../../database/index.js';
8
8
  import emitter from '../../emitter.js';
9
9
  import env from '../../env.js';
10
10
  import { RecordNotUniqueException } from '../../exceptions/database/record-not-unique.js';
11
- import { InvalidCredentialsException, InvalidProviderException } from '../../exceptions/index.js';
11
+ import { InvalidCredentialsException, InvalidProviderException, InvalidPayloadException, } from '../../exceptions/index.js';
12
12
  import logger from '../../logger.js';
13
13
  import { respond } from '../../middleware/respond.js';
14
14
  import { AuthenticationService } from '../../services/authentication.js';
15
15
  import { UsersService } from '../../services/users.js';
16
16
  import asyncHandler from '../../utils/async-handler.js';
17
17
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
18
+ import { isRedirectAllowedOnLogin } from '../../utils/is-redirect-allowed-on-login.js';
18
19
  import { LocalAuthDriver } from './local.js';
19
20
  // Register the samlify schema validator
20
21
  samlify.setSchemaValidator(validator);
@@ -89,7 +90,11 @@ export function createSAMLAuthRouter(providerName) {
89
90
  const { context: url } = sp.createLoginRequest(idp, 'redirect');
90
91
  const parsedUrl = new URL(url);
91
92
  if (req.query['redirect']) {
92
- parsedUrl.searchParams.append('RelayState', req.query['redirect']);
93
+ const redirect = req.query['redirect'];
94
+ if (isRedirectAllowedOnLogin(redirect, providerName) === false) {
95
+ throw new InvalidPayloadException(`URL "${redirect}" can't be used to redirect after login`);
96
+ }
97
+ parsedUrl.searchParams.append('RelayState', redirect);
93
98
  }
94
99
  return res.redirect(parsedUrl.toString());
95
100
  }));
@@ -97,11 +102,14 @@ export function createSAMLAuthRouter(providerName) {
97
102
  const { sp, idp } = getAuthProvider(providerName);
98
103
  const { context } = sp.createLogoutRequest(idp, 'redirect', req.body);
99
104
  const authService = new AuthenticationService({ accountability: req.accountability, schema: req.schema });
105
+ if (req.cookies[env['ACCESS_TOKEN_COOKIE_NAME']]) {
106
+ res.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_OPTIONS);
107
+ }
100
108
  if (req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
101
109
  const currentRefreshToken = req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']];
102
110
  if (currentRefreshToken) {
103
111
  await authService.logout(currentRefreshToken);
104
- res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], COOKIE_OPTIONS);
112
+ res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_OPTIONS);
105
113
  }
106
114
  }
107
115
  return res.redirect(context);
@@ -121,7 +129,8 @@ export function createSAMLAuthRouter(providerName) {
121
129
  },
122
130
  };
123
131
  if (relayState) {
124
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
132
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
133
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
125
134
  return res.redirect(relayState);
126
135
  }
127
136
  return next();
@@ -199,6 +199,18 @@ STORAGE_LOCAL_ROOT="./uploads"
199
199
  # The duration that the access token is valid ["15m"]
200
200
  ACCESS_TOKEN_TTL="15m"
201
201
 
202
+ # Whether or not to set the secure attribute for the access token cookie [false]
203
+ ACCESS_TOKEN_COOKIE_SECURE=false
204
+
205
+ # Value of sameSite for the access token cookie ["lax"]
206
+ ACCESS_TOKEN_COOKIE_SAME_SITE="lax"
207
+
208
+ # Name of the access token cookie ["directus_refresh_token"]
209
+ ACCESS_TOKEN_COOKIE_NAME="directus_session_token"
210
+
211
+ # Which domain to use for the access token cookie. Useful for development mode.
212
+ # ACCESS_TOKEN_COOKIE_DOMAIN
213
+
202
214
  # The duration that the refresh token is valid, and also how long users stay logged-in to the App ["7d"]
203
215
  REFRESH_TOKEN_TTL="7d"
204
216
 
@@ -8,8 +8,9 @@ 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
12
  export declare const GET_SET_HEADER: (cookieValue: string) => string;
13
+ export declare const ACCESS_COOKIE_OPTIONS: CookieOptions;
13
14
  export declare const OAS_REQUIRED_SCHEMAS: string[];
14
15
  /** Formats from which transformation is supported */
15
16
  export declare const SUPPORTED_IMAGE_TRANSFORM_FORMATS: string[];
package/dist/constants.js CHANGED
@@ -48,19 +48,28 @@ export const DEFAULT_AUTH_PROVIDER = 'default';
48
48
  export const COLUMN_TRANSFORMS = ['year', 'month', 'day', 'weekday', 'hour', 'minute', 'second'];
49
49
  export const GENERATE_SPECIAL = ['uuid', 'date-created', 'role-created', 'user-created'];
50
50
  export const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
51
- export const COOKIE_OPTIONS = {
51
+ export const REFRESH_COOKIE_OPTIONS = {
52
52
  httpOnly: true,
53
53
  domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
54
54
  maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
55
55
  secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
56
56
  sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
57
+ path: '/auth',
57
58
  };
58
59
  export const GET_SET_HEADER = (cookieValue) => {
59
60
  const domainHeader = env['REFRESH_TOKEN_COOKIE_DOMAIN'] ? ` Domain=${env['REFRESH_TOKEN_COOKIE_DOMAIN']};` : '';
60
61
  const partitionedHeader = env['REFRESH_TOKEN_COOKIE_PARTITIONED'] == true ? ' Partitioned;' : '';
61
- const cookieHeaderValue = `${env['REFRESH_TOKEN_COOKIE_NAME']}=${cookieValue}; HttpOnly; Max-Age=${getMilliseconds(env['REFRESH_TOKEN_TTL'])}; Secure=${env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false}; SameSite=${env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict'}; Path=/;${domainHeader}${partitionedHeader}`;
62
+ const cookieHeaderValue = `${env['REFRESH_TOKEN_COOKIE_NAME']}=${cookieValue}; HttpOnly; Max-Age=${getMilliseconds(env['REFRESH_TOKEN_TTL'])}; Secure=${env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false}; SameSite=${env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict'}; Path=/auth;${domainHeader}${partitionedHeader}`;
62
63
  return cookieHeaderValue;
63
64
  };
65
+ export const ACCESS_COOKIE_OPTIONS = {
66
+ httpOnly: true,
67
+ domain: env['ACCESS_TOKEN_COOKIE_DOMAIN'],
68
+ maxAge: getMilliseconds(env['ACCESS_TOKEN_TTL']),
69
+ secure: env['ACCESS_TOKEN_COOKIE_SECURE'] ?? false,
70
+ sameSite: env['ACCESS_TOKEN_COOKIE_SAME_SITE'] || 'strict',
71
+ path: '/',
72
+ };
64
73
  export const OAS_REQUIRED_SCHEMAS = ['Diff', 'Schema', 'Query', 'x-metadata'];
65
74
  /** Formats from which transformation is supported */
66
75
  export const SUPPORTED_IMAGE_TRANSFORM_FORMATS = ['image/jpeg', 'image/png', 'image/webp', 'image/tiff', 'image/avif'];
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import { createLDAPAuthRouter, createLocalAuthRouter, createOAuth2AuthRouter, createOpenIDAuthRouter, createSAMLAuthRouter, } from '../auth/drivers/index.js';
3
- import { DEFAULT_AUTH_PROVIDER, GET_SET_HEADER } from '../constants.js';
3
+ import { ACCESS_COOKIE_OPTIONS, DEFAULT_AUTH_PROVIDER, REFRESH_COOKIE_OPTIONS } from '../constants.js';
4
4
  import env from '../env.js';
5
5
  import { InvalidPayloadException } from '../exceptions/index.js';
6
6
  import logger from '../logger.js';
@@ -68,7 +68,8 @@ router.post('/refresh', asyncHandler(async (req, res, next) => {
68
68
  payload['data']['refresh_token'] = refreshToken;
69
69
  }
70
70
  if (mode === 'cookie') {
71
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
71
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
72
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
72
73
  }
73
74
  res.locals['payload'] = payload;
74
75
  return next();
@@ -93,13 +94,11 @@ router.post('/logout', asyncHandler(async (req, res, next) => {
93
94
  throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
94
95
  }
95
96
  await authenticationService.logout(currentRefreshToken);
97
+ if (req.cookies[env['ACCESS_TOKEN_COOKIE_NAME']]) {
98
+ res.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_OPTIONS);
99
+ }
96
100
  if (req.cookies[env['REFRESH_TOKEN_COOKIE_NAME']]) {
97
- res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], {
98
- httpOnly: true,
99
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
100
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
101
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
102
- });
101
+ res.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_OPTIONS);
103
102
  }
104
103
  return next();
105
104
  }), respond);
@@ -1,6 +1,7 @@
1
1
  import express from 'express';
2
2
  import Joi from 'joi';
3
- import { GET_SET_HEADER, UUID_REGEX } from '../constants.js';
3
+ import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS, UUID_REGEX } from '../constants.js';
4
+ import env from '../env.js';
4
5
  import { ForbiddenException, InvalidPayloadException } from '../exceptions/index.js';
5
6
  import { respond } from '../middleware/respond.js';
6
7
  import useCollection from '../middleware/use-collection.js';
@@ -24,7 +25,8 @@ router.post('/auth', asyncHandler(async (req, res, next) => {
24
25
  throw new InvalidPayloadException(error.message);
25
26
  }
26
27
  const { accessToken, refreshToken, expires } = await service.login(req.body);
27
- res.setHeader('Set-Cookie', GET_SET_HEADER(refreshToken));
28
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], accessToken, ACCESS_COOKIE_OPTIONS);
29
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
28
30
  res.locals['payload'] = { data: { access_token: accessToken, expires } };
29
31
  return next();
30
32
  }), respond);
package/dist/env.js CHANGED
@@ -34,6 +34,10 @@ const allowedEnvironmentVars = [
34
34
  'KEY',
35
35
  'SECRET',
36
36
  'ACCESS_TOKEN_TTL',
37
+ 'ACCESS_TOKEN_COOKIE_DOMAIN',
38
+ 'ACCESS_TOKEN_COOKIE_SECURE',
39
+ 'ACCESS_TOKEN_COOKIE_SAME_SITE',
40
+ 'ACCESS_TOKEN_COOKIE_NAME',
37
41
  'REFRESH_TOKEN_TTL',
38
42
  'REFRESH_TOKEN_COOKIE_DOMAIN',
39
43
  'REFRESH_TOKEN_COOKIE_SECURE',
@@ -143,6 +147,7 @@ const allowedEnvironmentVars = [
143
147
  'AUTH_.+_GROUP_SCOPE',
144
148
  'AUTH_.+_IDP.+',
145
149
  'AUTH_.+_SP.+',
150
+ 'AUTH_.+_REDIRECT_ALLOW_LIST',
146
151
  // extensions
147
152
  'PACKAGE_FILE_LOCATION',
148
153
  'EXTENSIONS_PATH',
@@ -211,6 +216,9 @@ const defaults = {
211
216
  RATE_LIMITER_GLOBAL_DURATION: 1,
212
217
  RATE_LIMITER_GLOBAL_STORE: 'memory',
213
218
  ACCESS_TOKEN_TTL: '15m',
219
+ ACCESS_TOKEN_COOKIE_SECURE: false,
220
+ ACCESS_TOKEN_COOKIE_SAME_SITE: 'lax',
221
+ ACCESS_TOKEN_COOKIE_NAME: 'directus_access_token',
214
222
  REFRESH_TOKEN_TTL: '7d',
215
223
  REFRESH_TOKEN_COOKIE_SECURE: false,
216
224
  REFRESH_TOKEN_COOKIE_SAME_SITE: 'lax',
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Authorization: Bearer
5
5
  * access_token query parameter
6
+ * Access token cookie
6
7
  *
7
8
  * and store in req.token
8
9
  */
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Authorization: Bearer
5
5
  * access_token query parameter
6
+ * Access token cookie
6
7
  *
7
8
  * and store in req.token
8
9
  */
10
+ import env from '../env.js';
9
11
  const extractToken = (req, _res, next) => {
10
12
  let token = null;
11
13
  if (req.query && req.query['access_token']) {
@@ -17,6 +19,12 @@ const extractToken = (req, _res, next) => {
17
19
  token = parts[1];
18
20
  }
19
21
  }
22
+ /**
23
+ * Check if there is an access token stored in the cookies
24
+ */
25
+ if (req?.cookies?.[env['ACCESS_TOKEN_COOKIE_NAME']]) {
26
+ token = req.cookies[env['ACCESS_TOKEN_COOKIE_NAME']];
27
+ }
20
28
  /**
21
29
  * @TODO
22
30
  * Look into RFC6750 compliance:
@@ -5,14 +5,13 @@ import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLID,
5
5
  import { GraphQLJSON, InputTypeComposer, ObjectTypeComposer, SchemaComposer, toInputObjectType } from 'graphql-compose';
6
6
  import { flatten, get, mapKeys, merge, omit, pick, set, transform, uniq } from 'lodash-es';
7
7
  import { clearSystemCache, getCache } from '../../cache.js';
8
- import { DEFAULT_AUTH_PROVIDER, GENERATE_SPECIAL } from '../../constants.js';
8
+ import { ACCESS_COOKIE_OPTIONS, DEFAULT_AUTH_PROVIDER, GENERATE_SPECIAL, REFRESH_COOKIE_OPTIONS, } from '../../constants.js';
9
9
  import getDatabase from '../../database/index.js';
10
10
  import env from '../../env.js';
11
11
  import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../../exceptions/index.js';
12
12
  import { getExtensionManager } from '../../extensions.js';
13
13
  import { generateHash } from '../../utils/generate-hash.js';
14
14
  import { getGraphQLType } from '../../utils/get-graphql-type.js';
15
- import { getMilliseconds } from '../../utils/get-milliseconds.js';
16
15
  import { reduceSchema } from '../../utils/reduce-schema.js';
17
16
  import { sanitizeQuery } from '../../utils/sanitize-query.js';
18
17
  import { validateQuery } from '../../utils/validate-query.js';
@@ -1696,13 +1695,8 @@ export class GraphQLService {
1696
1695
  });
1697
1696
  const result = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, args?.otp);
1698
1697
  if (args['mode'] === 'cookie') {
1699
- res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], result['refreshToken'], {
1700
- httpOnly: true,
1701
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
1702
- maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
1703
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
1704
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
1705
- });
1698
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], result['accessToken'], ACCESS_COOKIE_OPTIONS);
1699
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], result['refreshToken'], REFRESH_COOKIE_OPTIONS);
1706
1700
  }
1707
1701
  return {
1708
1702
  access_token: result['accessToken'],
@@ -1737,13 +1731,8 @@ export class GraphQLService {
1737
1731
  }
1738
1732
  const result = await authenticationService.refresh(currentRefreshToken);
1739
1733
  if (args['mode'] === 'cookie') {
1740
- res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], result['refreshToken'], {
1741
- httpOnly: true,
1742
- domain: env['REFRESH_TOKEN_COOKIE_DOMAIN'],
1743
- maxAge: getMilliseconds(env['REFRESH_TOKEN_TTL']),
1744
- secure: env['REFRESH_TOKEN_COOKIE_SECURE'] ?? false,
1745
- sameSite: env['REFRESH_TOKEN_COOKIE_SAME_SITE'] || 'strict',
1746
- });
1734
+ res?.cookie(env['ACCESS_TOKEN_COOKIE_NAME'], result['accessToken'], ACCESS_COOKIE_OPTIONS);
1735
+ res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], result['refreshToken'], REFRESH_COOKIE_OPTIONS);
1747
1736
  }
1748
1737
  return {
1749
1738
  access_token: result['accessToken'],
@@ -1757,7 +1746,7 @@ export class GraphQLService {
1757
1746
  args: {
1758
1747
  refresh_token: GraphQLString,
1759
1748
  },
1760
- resolve: async (_, args, { req }) => {
1749
+ resolve: async (_, args, { req, res }) => {
1761
1750
  const accountability = { role: null };
1762
1751
  if (req?.ip)
1763
1752
  accountability.ip = req.ip;
@@ -1776,6 +1765,12 @@ export class GraphQLService {
1776
1765
  throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
1777
1766
  }
1778
1767
  await authenticationService.logout(currentRefreshToken);
1768
+ if (req?.cookies?.[env['ACCESS_TOKEN_COOKIE_NAME']]) {
1769
+ res?.clearCookie(env['ACCESS_TOKEN_COOKIE_NAME'], ACCESS_COOKIE_OPTIONS);
1770
+ }
1771
+ if (req?.cookies?.[env['REFRESH_TOKEN_COOKIE_NAME']]) {
1772
+ res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'], REFRESH_COOKIE_OPTIONS);
1773
+ }
1779
1774
  return true;
1780
1775
  },
1781
1776
  },
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Checks if the defined redirect after successful SSO login is in the allow list
3
+ */
4
+ export declare function isRedirectAllowedOnLogin(redirect: unknown, provider: string): boolean;
@@ -0,0 +1,42 @@
1
+ import { toArray } from '@wbce-d9/utils';
2
+ import env from '../env.js';
3
+ import isUrlAllowed from './is-url-allowed.js';
4
+ function isUrlValid(string) {
5
+ try {
6
+ new URL(string);
7
+ return true;
8
+ }
9
+ catch (err) {
10
+ return false;
11
+ }
12
+ }
13
+ /**
14
+ * Checks if the defined redirect after successful SSO login is in the allow list
15
+ */
16
+ export function isRedirectAllowedOnLogin(redirect, provider) {
17
+ if (!redirect)
18
+ return true; // empty redirect
19
+ if (typeof redirect !== 'string')
20
+ return false; // invalid type
21
+ const publicUrl = env['PUBLIC_URL'];
22
+ if (!isUrlValid(redirect)) {
23
+ if (!redirect.startsWith('//')) {
24
+ // should be a relative path like `/admin/test`
25
+ return true;
26
+ }
27
+ // domain without protocol `//example.com/test`
28
+ return false;
29
+ }
30
+ const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);
31
+ const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
32
+ if (envKey in env) {
33
+ if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
34
+ return true;
35
+ }
36
+ if (!isUrlValid(publicUrl)) {
37
+ return false;
38
+ }
39
+ // allow redirects to the defined PUBLIC_URL
40
+ const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);
41
+ return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
1
  /**
2
- * Check if url matches allow list either exactly or by domain+path
2
+ * Check if url matches allow list either exactly or by origin+pathname
3
3
  */
4
4
  export default function isUrlAllowed(url: string, allowList: string | string[]): boolean;
@@ -2,7 +2,7 @@ import { toArray } from '@wbce-d9/utils';
2
2
  import logger from '../logger.js';
3
3
  import { URL } from 'url';
4
4
  /**
5
- * Check if url matches allow list either exactly or by domain+path
5
+ * Check if url matches allow list either exactly or by origin+pathname
6
6
  */
7
7
  export default function isUrlAllowed(url, allowList) {
8
8
  const urlAllowList = toArray(allowList);
@@ -11,8 +11,8 @@ export default function isUrlAllowed(url, allowList) {
11
11
  const parsedWhitelist = urlAllowList
12
12
  .map((allowedURL) => {
13
13
  try {
14
- const { hostname, pathname } = new URL(allowedURL);
15
- return hostname + pathname;
14
+ const { origin, pathname } = new URL(allowedURL);
15
+ return origin + pathname;
16
16
  }
17
17
  catch {
18
18
  logger.warn(`Invalid URL used "${url}"`);
@@ -21,8 +21,8 @@ export default function isUrlAllowed(url, allowList) {
21
21
  })
22
22
  .filter((f) => f);
23
23
  try {
24
- const { hostname, pathname } = new URL(url);
25
- return parsedWhitelist.includes(hostname + pathname);
24
+ const { origin, pathname } = new URL(url);
25
+ return parsedWhitelist.includes(origin + pathname);
26
26
  }
27
27
  catch {
28
28
  return false;
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "10.3.0",
3
+ "version": "10.3.1",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -72,7 +72,7 @@
72
72
  "@wbce-d9/format-title": "10.0.0",
73
73
  "argon2": "0.30.3",
74
74
  "async": "3.2.4",
75
- "axios": "1.3.6",
75
+ "axios": "^1.7.2",
76
76
  "busboy": "1.6.0",
77
77
  "bytes": "3.1.2",
78
78
  "camelcase": "7.0.1",
@@ -143,20 +143,20 @@
143
143
  "uuid-validate": "0.0.3",
144
144
  "vm2": "^3.9.19",
145
145
  "wellknown": "0.5.0",
146
- "@wbce-d9/app": "9.29.0",
147
- "@wbce-d9/constants": "9.26.3",
148
- "@wbce-d9/exceptions": "9.26.3",
149
- "@wbce-d9/extensions-sdk": "9.26.5",
150
- "@wbce-d9/schema": "9.26.3",
151
- "@wbce-d9/storage-driver-azure": "9.26.5",
152
- "@wbce-d9/specs": "9.26.3",
153
- "@wbce-d9/storage": "9.26.3",
154
- "@wbce-d9/storage-driver-cloudinary": "9.26.5",
155
- "@wbce-d9/storage-driver-gcs": "9.26.5",
156
- "@wbce-d9/storage-driver-s3": "9.26.7",
157
- "@wbce-d9/update-check": "9.26.3",
158
- "@wbce-d9/utils": "9.28.0",
159
- "@wbce-d9/storage-driver-local": "9.26.5"
146
+ "@wbce-d9/app": "9.29.1",
147
+ "@wbce-d9/constants": "9.26.4",
148
+ "@wbce-d9/extensions-sdk": "9.26.6",
149
+ "@wbce-d9/specs": "9.26.4",
150
+ "@wbce-d9/exceptions": "9.26.4",
151
+ "@wbce-d9/storage": "9.26.4",
152
+ "@wbce-d9/schema": "9.26.4",
153
+ "@wbce-d9/storage-driver-azure": "9.26.6",
154
+ "@wbce-d9/storage-driver-cloudinary": "9.26.6",
155
+ "@wbce-d9/storage-driver-gcs": "9.26.6",
156
+ "@wbce-d9/storage-driver-local": "9.26.6",
157
+ "@wbce-d9/storage-driver-s3": "9.26.8",
158
+ "@wbce-d9/update-check": "9.26.4",
159
+ "@wbce-d9/utils": "9.28.1"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@ngneat/falso": "6.4.0",
@@ -204,7 +204,7 @@
204
204
  "supertest": "6.3.3",
205
205
  "typescript": "5.0.4",
206
206
  "vitest": "0.30.1",
207
- "@wbce-d9/types": "9.26.3"
207
+ "@wbce-d9/types": "9.26.4"
208
208
  },
209
209
  "optionalDependencies": {
210
210
  "@keyv/redis": "2.5.7",