CVE-2024-28239: Insufficient Information (NVD-CWE-noinfo)
value: Directus is a real-time API and App dashboard for managing SQL database content. The authentication API has a `redirect` parameter that can be exploited as an open redirect vulnerability as the user tries to log in via the API URL. There's a redirect that is done after successful login via the Auth API GET request to `directus/auth/login/google?redirect=http://malicious-fishing-site.com`. While credentials don't seem to be passed to the attacker site, the user can be phished into clicking a legitimate directus site and be taken to a malicious site made to look like a an error message "Your password needs to be updated" to phish out the current password. Users who login via OAuth2 into Directus may be at risk. This issue has been addressed in version 10.10.0. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-12T21:15Z
Repository: https://github.com/directus/directus
Language: TypeScript
Files changed: .changeset/calm-tables-destroy.md, api/src/auth/drivers/oauth2.ts, api/src/auth/drivers/openid.ts, api/src/auth/drivers/saml.ts, api/src/utils/is-login-redirect-allowed.test.ts, api/src/utils/is-login-redirect-allowed.ts, api/src/utils/is-url-allowed.test.ts, api/src/utils/is-url-allowed.ts, docs/releases/breaking-changes.md, docs/self-hosted/config-options.md, packages/env/src/constants/directus-variables.ts
Commit date: 2024-03-04 04:43:48 +0000
Commit message: Fix URL Redirection in OAuth2/OpenID/SAML (#21238)

Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Diff: diff --git a/.changeset/calm-tables-destroy.md b/.changeset/calm-tables-destroy.md
new file mode 100644
index 0000000000..3b9cc43571
--- /dev/null
+++ b/.changeset/calm-tables-destroy.md
@@ -0,0 +1,6 @@
+---
+"@directus/api": patch
+"@directus/env": patch
+---
+
+Fixed potential Open Redirect vulnerability with OAuth2/OpenID/SAML SSO providers (via Directus redirect query parameter), by requiring redirect URLs to be enabled via allow list
diff --git a/api/src/auth/drivers/oauth2.ts b/api/src/auth/drivers/oauth2.ts
index d39d4356fb..8e8c1aac2c 100644
--- a/api/src/auth/drivers/oauth2.ts
+++ b/api/src/auth/drivers/oauth2.ts
@@ -1,9 +1,10 @@
 import { useEnv } from '@directus/env';
 import {
 	ErrorCode,
 	InvalidCredentialsError,
+	InvalidPayloadError,
 	InvalidProviderConfigError,
 	InvalidProviderError,
 	InvalidTokenError,
 	isDirectusError,
 	ServiceUnavailableError,
@@ -14,23 +15,24 @@ import express, { Router } from 'express';
 import { flatten } from 'flat';
 import jwt from 'jsonwebtoken';
 import type { Client } from 'openid-client';
 import { errors, generators, Issuer } from 'openid-client';
 import { getAuthProvider } from '../../auth.js';
+import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
 import getDatabase from '../../database/index.js';
 import emitter from '../../emitter.js';
 import { useLogger } from '../../logger.js';
 import { respond } from '../../middleware/respond.js';
 import { AuthenticationService } from '../../services/authentication.js';
 import { UsersService } from '../../services/users.js';
 import type { AuthData, AuthDriverOptions, User } from '../../types/index.js';
 import asyncHandler from '../../utils/async-handler.js';
 import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
 import { getIPFromReq } from '../../utils/get-ip-from-req.js';
+import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
 import { Url } from '../../utils/url.js';
 import { LocalAuthDriver } from './local.js';
-import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
 
 export class OAuth2AuthDriver extends LocalAuthDriver {
 	client: Client;
 	redirectUrl: string;
 	usersService: UsersService;
@@ -296,19 +298,20 @@ export function createOAuth2AuthRouter(providerName: string): Router {
 		'/',
 		(req, res) => {
 			const provider = getAuthProvider(providerName) as OAuth2AuthDriver;
 			const codeVerifier = provider.generateCodeVerifier();
 			const prompt = !!req.query['prompt'];
+			const redirect = req.query['redirect'];
 
-			const token = jwt.sign(
-				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
-				env['SECRET'] as string,
-				{
-					expiresIn: '5m',
-					issuer: 'directus',
-				},
-			);
+			if (isLoginRedirectAllowed(redirect, providerName) === false) {
+				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
+			}
+
+			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
+				expiresIn: '5m',
+				issuer: 'directus',
+			});
 
 			res.cookie(`oauth2.${providerName}`, token, {
 				httpOnly: true,
 				sameSite: 'lax',
 			});
diff --git a/api/src/auth/drivers/openid.ts b/api/src/auth/drivers/openid.ts
index 732a2ad7af..90166d7ad1 100644
--- a/api/src/auth/drivers/openid.ts
+++ b/api/src/auth/drivers/openid.ts
@@ -1,9 +1,10 @@
 import { useEnv } from '@directus/env';
 import {
 	ErrorCode,
 	InvalidCredentialsError,
+	InvalidPayloadError,
 	InvalidProviderConfigError,
 	InvalidProviderError,
 	InvalidTokenError,
 	isDirectusError,
 	ServiceUnavailableError,
@@ -14,23 +15,24 @@ import express, { Router } from 'express';
 import { flatten } from 'flat';
 import jwt from 'jsonwebtoken';
 import type { Client } from 'openid-client';
 import { errors, generators, Issuer } from 'openid-client';
 import { getAuthProvider } from '../../auth.js';
+import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
 import getDatabase from '../../database/index.js';
 import emitter from '../../emitter.js';
 import { useLogger } from '../../logger.js';
 import { respond } from '../../middleware/respond.js';
 import { AuthenticationService } from '../../services/authentication.js';
 import { UsersService } from '../../services/users.js';
 import type { AuthData, AuthDriverOptions, User } from '../../types/index.js';
 import asyncHandler from '../../utils/async-handler.js';
 import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
 import { getIPFromReq } from '../../utils/get-ip-from-req.js';
+import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
 import { Url } from '../../utils/url.js';
 import { LocalAuthDriver } from './local.js';
-import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
 
 export class OpenIDAuthDriver extends LocalAuthDriver {
 	client: Promise<Client>;
 	redirectUrl: string;
 	usersService: UsersService;
@@ -318,28 +320,28 @@ const handleError = (e: any) => {
 	return e;
 };
 
 export function createOpenIDAuthRouter(providerName: string): Router {
 	const env = useEnv();
-
 	const router = Router();
 
 	router.get(
 		'/',
 		asyncHandler(async (req, res) => {
 			const provider = getAuthProvider(providerName) as OpenIDAuthDriver;
 			const codeVerifier = provider.generateCodeVerifier();
 			const prompt = !!req.query['prompt'];
+			const redirect = req.query['redirect'];
 
-			const token = jwt.sign(
-				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
-				env['SECRET'] as string,
-				{
-					expiresIn: '5m',
-					issuer: 'directus',
-				},
-			);
+			if (isLoginRedirectAllowed(redirect, providerName) === false) {
+				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
+			}
+
+			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
+				expiresIn: '5m',
+				issuer: 'directus',
+			});
 
 			res.cookie(`openid.${providerName}`, token, {
 				httpOnly: true,
 				sameSite: 'lax',
 			});
diff --git a/api/src/auth/drivers/saml.ts b/api/src/auth/drivers/saml.ts
index 1b2cfad021..caf8a16aed 100644
--- a/api/src/auth/drivers/saml.ts
+++ b/api/src/auth/drivers/saml.ts
@@ -1,8 +1,14 @@
 import * as validator from '@authenio/samlify-node-xmllint';
 import { useEnv } from '@directus/env';
-import { ErrorCode, InvalidCredentialsError, InvalidProviderError, isDirectusError } from '@directus/errors';
+import {
+	ErrorCode,
+	InvalidCredentialsError,
+	InvalidPayloadError,
+	InvalidProviderError,
+	isDirectusError,
+} from '@directus/errors';
 import express, { Router } from 'express';
 import * as samlify from 'samlify';
 import { getAuthProvider } from '../../auth.js';
 import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
 import getDatabase from '../../database/index.js';
@@ -13,10 +19,11 @@ import { AuthenticationService } from '../../services/authentication.js';
 import { UsersService } from '../../services/users.js';
 import type { AuthDriverOptions, User } from '../../types/index.js';
 import asyncHandler from '../../utils/async-handler.js';
 import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
 import { LocalAuthDriver } from './local.js';
+import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
 
 // Register the samlify schema validator
 samlify.setSchemaValidator(validator);
 
 export class SAMLAuthDriver extends LocalAuthDriver {
@@ -124,11 +131,17 @@ export function createSAMLAuthRouter(providerName: string) {
 			const { sp, idp } = getAuthProvider(providerName) as SAMLAuthDriver;
 			const { context: url } = sp.createLoginRequest(idp, 'redirect');
 			const parsedUrl = new URL(url);
 
 			if (req.query['redirect']) {
-				parsedUrl.searchParams.append('RelayState', req.query['redirect'] as string);
+				const redirect = req.query['redirect'] as string;
+
+				if (isLoginRedirectAllowed(redirect, providerName) === false) {
+					throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
+				}
+
+				parsedUrl.searchParams.append('RelayState', redirect);
 			}
 
 			return res.redirect(parsedUrl.toString());
 		}),
 	);
diff --git a/api/src/utils/is-login-redirect-allowed.test.ts b/api/src/utils/is-login-redirect-allowed.test.ts
new file mode 100644
index 0000000000..b59b41f803
--- /dev/null
+++ b/api/src/utils/is-login-redirect-allowed.test.ts
@@ -0,0 +1,78 @@
+import { vi, expect, test, afterEach } from 'vitest';
+import { useEnv } from '@directus/env';
+import { isLoginRedirectAllowed } from './is-login-redirect-allowed.js';
+
+vi.mock('@directus/env');
+
+afterEach(() => {
+	vi.clearAllMocks();
+});
+
+test('isLoginRedirectAllowed returns true with no redirect', () => {
+	const redirect = undefined;
+	const provider = 'local';
+
+	expect(isLoginRedirectAllowed(redirect, provider)).toBe(true);
+});
+
+test('isLoginRedirectAllowed returns false with invalid redirect', () => {
+	const redirect = 123456;
+	const provider = 'local';
+
+	expect(isLoginRedirectAllowed(redirect, provider)).toBe(false);
+});
+
+test('isLoginRedirectAllowed returns true for allowed URL', () => {
+	const provider = 'local';
+
+	vi.mocked(useEnv).mockReturnValue({
+		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]:
+			'http://external.example.com,https://external.example.com,http://external.example.com:8055/test',
+		PUBLIC_URL: 'http://public.example.com',
+	});
+
+	expect(isLoginRedirectAllowed('http://public.example.com', provider)).toBe(true);
+	expect(isLoginRedirectAllowed('http://external.example.com', provider)).toBe(true);
+	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(true);
+	expect(isLoginRedirectAllowed('http://external.example.com:8055/test', provider)).toBe(true);
+});
+
+test('isLoginRedirectAllowed returns false for denied URL', () => {
+	const provider = 'local';
+
+	vi.mocked(useEnv).mockReturnValue({
+		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
+		PUBLIC_URL: 'http://public.example.com',
+	});
+
+	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(false);
+	expect(isLoginRedirectAllowed('http://external.example.com:8055', provider)).toBe(false);
+	expect(isLoginRedirectAllowed('http://external.example.com/test', provider)).toBe(false);
+});
+
+test('isLoginRedirectAllowed returns true for relative paths', () => {
+	const provider = 'local';
+
+	vi.mocked(useEnv).mockReturnValue({
+		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
+		PUBLIC_URL: 'http://public.example.com',
+	});
+
+	expect(isLoginRedirectAllowed('/admin/content', provider)).toBe(true);
+	expect(isLoginRedirectAllowed('../admin/content', provider)).toBe(true);
+	expect(isLoginRedirectAllowed('./admin/content', provider)).toBe(true);
+
+	expect(isLoginRedirectAllowed('http://public.example.com/admin/content', provider)).toBe(true);
+});
+
+test('isLoginRedirectAllowed returns false if missing protocol', () => {
+	const provider = 'local';
+
+	vi.mocked(useEnv).mockReturnValue({
+		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://example.com',
+		PUBLIC_URL: 'http://example.com',
+	});
+
+	expect(isLoginRedirectAllowed('//example.com/admin/content', provider)).toBe(false);
+	expect(isLoginRedirectAllowed('//user@password:example.com/', provider)).toBe(false);
+});
diff --git a/api/src/utils/is-login-redirect-allowed.ts b/api/src/utils/is-login-redirect-allowed.ts
new file mode 100644
index 0000000000..dd9b9f37d2
--- /dev/null
+++ b/api/src/utils/is-login-redirect-allowed.ts
@@ -0,0 +1,41 @@
+import { useEnv } from '@directus/env';
+import { toArray } from '@directus/utils';
+import isUrlAllowed from './is-url-allowed.js';
+
+/**
+ * Checks if the defined redirect after successful SSO login is in the allow list
+ */
+export function isLoginRedirectAllowed(redirect: unknown, provider: string): boolean {
+	if (!redirect) return true; // empty redirect
+	if (typeof redirect !== 'string') return false; // invalid type
+
+	const env = useEnv();
+	const publicUrl = env['PUBLIC_URL'] as string;
+
+	if (URL.canParse(redirect) === false) {
+		if (redirect.startsWith('//') === false) {
+			// should be a relative path like `/admin/test`
+			return true;
+		}
+
+		// domain without protocol `//example.com/test`
+		return false;
+	}
+
+	const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);
+
+	const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
+
+	if (envKey in env) {
+		if (isUrlAllowed(redirect, [...toArray(env[envKey] as string), publicUrl])) return true;
+	}
+
+	if (URL.canParse(publicUrl) === false) {
+		return false;
+	}
+
+	// allow redirects to the defined PUBLIC_URL
+	const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);
+
+	return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
+}
diff --git a/api/src/utils/is-url-allowed.test.ts b/api/src/utils/is-url-allowed.test.ts
new file mode 100644
index 0000000000..40def8ed34
--- /dev/null
+++ b/api/src/utils/is-url-allowed.test.ts
@@ -0,0 +1,37 @@
+import { expect, test } from 'vitest';
+import isUrlAllowed from './is-url-allowed.js';
+
+test('isUrlAllowed should allow matching domain', () => {
+	const checkUrl = 'https://directus.io';
+	const allowedUrls = ['https://directus.io/'];
+
+	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
+});
+
+test('isUrlAllowed should allow matching path', () => {
+	const checkUrl = 'https://directus.io/tv';
+	const allowedUrls = ['https://directus.io/tv'];
+
+	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
+});
+
+test('isUrlAllowed should block different paths', () => {
+	const checkUrl = 'http://example.com/test1';
+	const allowedUrls = ['http://example.com/test2', 'http://example.com/test3', 'http://example.com/'];
+
+	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
+});
+
+test('isUrlAllowed should block different domains', () => {
+	const checkUrl = 'http://directus.io/';
+	const allowedUrls = ['http://example.com/', 'http://directus.chat'];
+
+	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
+});
+
+test('isUrlAllowed blocks varying protocols', () => {
+	const checkUrl = 'http://example.com/';
+	const allowedUrls = ['ftp://example.com/', 'https://example.com/'];
+
+	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
+});
diff --git a/api/src/utils/is-url-allowed.ts b/api/src/utils/is-url-allowed.ts
index 3974e7e589..b5f8912836 100644
--- a/api/src/utils/is-url-allowed.ts
+++ b/api/src/utils/is-url-allowed.ts
@@ -1,11 +1,11 @@
 import { toArray } from '@directus/utils';
 import { URL } from 'url';
 import { useLogger } from '../logger.js';
 
 /**
- * Check if url matches allow list either exactly or by domain+path
+ * Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
  */
 export default function isUrlAllowed(url: string, allowList: string | string[]): boolean {
 	const logger = useLogger();
 
 	const urlAllowList = toArray(allowList);
@@ -13,22 +13,22 @@ export default function isUrlAllowed(url: string, allowList: string | string[]):
 	if (urlAllowList.includes(url)) return true;
 
 	const parsedWhitelist = urlAllowList
 		.map((allowedURL) => {
 			try {
-				const { hostname, pathname } = new URL(allowedURL);
-				return hostname + pathname;
+				const { origin, pathname } = new URL(allowedURL);
+				return origin + pathname;
 			} catch {
 				logger.warn(`Invalid URL used "${url}"`);
 			}
 
 			return null;
 		})
 		.filter((f) => f) as string[];
 
 	try {
-		const { hostname, pathname } = new URL(url);
-		return parsedWhitelist.includes(hostname + pathname);
+		const { origin, pathname } = new URL(url);
+		return parsedWhitelist.includes(origin + pathname);
 	} catch {
 		return false;
 	}
 }
diff --git a/docs/releases/breaking-changes.md b/docs/releases/breaking-changes.md
index 4d78a1514f..f7fd0cb909 100644
--- a/docs/releases/breaking-changes.md
+++ b/docs/releases/breaking-changes.md
@@ -132,10 +132,21 @@ AuthenticationService.login('email', 'password', 'otp-code');
 AuthenticationService.login('email', 'password', { otp: 'otp-code', session: true });
 ```
 
 :::
 
+### Introduced Allow List for OAuth2/OpenID/SAML Redirects
+
+Due to an Open Redirect vulnerability with the OAuth2, OpenID and SAML SSO providers, we have introduced an allow list
+for these redirects.
+
+If your current workflow depends on redirecting to an external domain after successful SSO login using the
+`?redirect=http://example.com/login` query parameter, then you'll need to add this URL to the
+`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` config option.
+
+`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` accepts a comma-separated list of URLs (path is included in comparison).
+
 ## Version 10.9.0
 
 ### Updated Exif Tags
 
 The library `exif-reader`, which is used for Exif metadata extraction of images, has been updated to v2. In this
diff --git a/docs/self-hosted/config-options.md b/docs/self-hosted/config-options.md
index 60378e4801..e32ba7dad0 100644
--- a/docs/self-hosted/config-options.md
+++ b/docs/self-hosted/config-options.md
@@ -745,48 +745,50 @@ These flows rely on the `PUBLIC_URL` variable for redirecting. Ensure the variab
 
 :::
 
 ### OAuth 2.0
 
-| Variable                                    | Description                                                                                       | Default Value    |
-| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------- |
-| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                         | --               |
-| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                             | --               |
-| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `email`          |
-| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                     | --               |
-| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                           | --               |
-| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                           | --               |
-| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | --               |
-| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                           | `email`          |
-| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                      | --               |
-| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                       | --               |
-| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`          |
-| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --               |
-| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle` |
-| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`     |
-| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --               |
+| Variable                                    | Description                                                                                               | Default Value    |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------- |
+| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                                 | --               |
+| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                                     | --               |
+| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `email`          |
+| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                             | --               |
+| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                                   | --               |
+| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                                   | --               |
+| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | --               |
+| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `email`          |
+| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                              | --               |
+| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                               | --               |
+| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`          |
+| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --               |
+| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle` |
+| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`     |
+| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --               |
+| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --               |
 
 <sup>[1]</sup> When authenticating, Directus will match the identifier value from the external user profile to a
 Directus users "External Identifier".
 
 ### OpenID
 
 OpenID is an authentication protocol built on OAuth 2.0, and should be preferred over standard OAuth 2.0 where possible.
 
-| Variable                                    | Description                                                                                       | Default Value          |
-| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- |
-| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                       | --                     |
-| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                           | --                     |
-| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `openid profile email` |
-| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                              | --                     |
-| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | `sub`<sup>[2]</sup>    |
-| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`                |
-| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                           | `false`                |
-| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --                     |
-| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle`       |
-| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`           |
-| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --                     |
+| Variable                                    | Description                                                                                               | Default Value          |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
+| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                               | --                     |
+| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                                   | --                     |
+| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `openid profile email` |
+| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                                      | --                     |
+| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | `sub`<sup>[2]</sup>    |
+| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                |
+| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                                   | `false`                |
+| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                     |
+| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle`       |
+| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`           |
+| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --                     |
+| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                     |
 
 <sup>[1]</sup> When authenticating, Directus will match the identifier value from the external user profile to a
 Directus users "External Identifier".
 
 <sup>[2]</sup> `sub` represents a unique user identifier defined by the OpenID provider. For users not relying on
@@ -846,20 +848,21 @@ without a password.
 - Service provider (SP) agrees to trust the identity provider to authenticate users.
 
 - Identity provider (IdP) authenticates users and provides to service providers an authentication assertion that
   indicates a user has been authenticated.
 
-| Variable                                    | Description                                             | Default Value                                                          |
-| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- |
-| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider     | --                                                                     |
-| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider    | --                                                                     |
-| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users. | `false`                                                                |
-| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.             | --                                                                     |
-| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.             | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
-| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
-| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                              | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
-| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |
+| Variable                                    | Description                                                                                               | Default Value                                                          |
+| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider                                                       | --                                                                     |
+| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider                                                      | --                                                                     |
+| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                                                                |
+| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                                                                     |
+| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.                                                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
+| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
+| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                                                                                | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
+| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                                                                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |
+| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                                                                     |
 
 <sup>[1]</sup> When authenticating, Directus will match the identifier value from the external user profile to a
 Directus users "External Identifier".
 
 The `SP_metadata` and `IDP_metadata` variables should be set to the XML metadata provided by the service provider and
diff --git a/packages/env/src/constants/directus-variables.ts b/packages/env/src/constants/directus-variables.ts
index efaf3e95ab..874cf9f214 100644
--- a/packages/env/src/constants/directus-variables.ts
+++ b/packages/env/src/constants/directus-variables.ts
@@ -151,10 +151,11 @@ export const DIRECTUS_VARIABLES = [
 	'AUTH_.+_GROUP_DN',
 	'AUTH_.+_GROUP_ATTRIBUTE',
 	'AUTH_.+_GROUP_SCOPE',
 	'AUTH_.+_IDP.+',
 	'AUTH_.+_SP.+',
+	'AUTH_.+_REDIRECT_ALLOW_LIST',
 	// extensions
 	'PACKAGE_FILE_LOCATION',
 	'EXTENSIONS_LOCATION',
 	'EXTENSIONS_PATH',
 	'EXTENSIONS_MUST_LOAD',

Vulnerable code: import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
			const token = jwt.sign(
				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
				env['SECRET'] as string,
				{
					expiresIn: '5m',
					issuer: 'directus',
				},
			);
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';

			const token = jwt.sign(
				{ verifier: codeVerifier, redirect: req.query['redirect'], prompt },
				env['SECRET'] as string,
				{
					expiresIn: '5m',
					issuer: 'directus',
				},
			);
import { ErrorCode, InvalidCredentialsError, InvalidProviderError, isDirectusError } from '@directus/errors';
				parsedUrl.searchParams.append('RelayState', req.query['redirect'] as string);
 * Check if url matches allow list either exactly or by domain+path
				const { hostname, pathname } = new URL(allowedURL);
				return hostname + pathname;
		const { hostname, pathname } = new URL(url);
		return parsedWhitelist.includes(hostname + pathname);
| Variable                                    | Description                                                                                       | Default Value    |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                         | --               |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                             | --               |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `email`          |
| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                     | --               |
| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                           | --               |
| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                           | --               |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | --               |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                           | `email`          |
| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                      | --               |
| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                       | --               |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`          |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --               |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle` |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`     |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --               |
| Variable                                    | Description                                                                                       | Default Value          |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                       | --                     |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                           | --                     |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                           | `openid profile email` |
| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                              | --                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                          | `sub`<sup>[2]</sup>    |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                           | `false`                |
| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                           | `false`                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                       | --                     |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons). | `account_circle`       |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                    | `<PROVIDER>`           |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                         | --                     |
| Variable                                    | Description                                             | Default Value                                                          |
| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- |
| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider     | --                                                                     |
| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider    | --                                                                     |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users. | `false`                                                                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.             | --                                                                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.             | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                              | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |
Fixed code: ---
"@directus/api": patch
"@directus/env": patch
---

Fixed potential Open Redirect vulnerability with OAuth2/OpenID/SAML SSO providers (via Directus redirect query parameter), by requiring redirect URLs to be enabled via allow list
	InvalidPayloadError,
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
			const redirect = req.query['redirect'];
			if (isLoginRedirectAllowed(redirect, providerName) === false) {
				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
			}

			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
				expiresIn: '5m',
				issuer: 'directus',
			});
	InvalidPayloadError,
import { REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../constants.js';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
			const redirect = req.query['redirect'];
			if (isLoginRedirectAllowed(redirect, providerName) === false) {
				throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
			}

			const token = jwt.sign({ verifier: codeVerifier, redirect, prompt }, env['SECRET'] as string, {
				expiresIn: '5m',
				issuer: 'directus',
			});
import {
	ErrorCode,
	InvalidCredentialsError,
	InvalidPayloadError,
	InvalidProviderError,
	isDirectusError,
} from '@directus/errors';
import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
				const redirect = req.query['redirect'] as string;

				if (isLoginRedirectAllowed(redirect, providerName) === false) {
					throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
				}

				parsedUrl.searchParams.append('RelayState', redirect);
import { vi, expect, test, afterEach } from 'vitest';
import { useEnv } from '@directus/env';
import { isLoginRedirectAllowed } from './is-login-redirect-allowed.js';

vi.mock('@directus/env');

afterEach(() => {
	vi.clearAllMocks();
});

test('isLoginRedirectAllowed returns true with no redirect', () => {
	const redirect = undefined;
	const provider = 'local';

	expect(isLoginRedirectAllowed(redirect, provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false with invalid redirect', () => {
	const redirect = 123456;
	const provider = 'local';

	expect(isLoginRedirectAllowed(redirect, provider)).toBe(false);
});

test('isLoginRedirectAllowed returns true for allowed URL', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]:
			'http://external.example.com,https://external.example.com,http://external.example.com:8055/test',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('http://public.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('http://external.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(true);
	expect(isLoginRedirectAllowed('http://external.example.com:8055/test', provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false for denied URL', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('https://external.example.com', provider)).toBe(false);
	expect(isLoginRedirectAllowed('http://external.example.com:8055', provider)).toBe(false);
	expect(isLoginRedirectAllowed('http://external.example.com/test', provider)).toBe(false);
});

test('isLoginRedirectAllowed returns true for relative paths', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://external.example.com',
		PUBLIC_URL: 'http://public.example.com',
	});

	expect(isLoginRedirectAllowed('/admin/content', provider)).toBe(true);
	expect(isLoginRedirectAllowed('../admin/content', provider)).toBe(true);
	expect(isLoginRedirectAllowed('./admin/content', provider)).toBe(true);

	expect(isLoginRedirectAllowed('http://public.example.com/admin/content', provider)).toBe(true);
});

test('isLoginRedirectAllowed returns false if missing protocol', () => {
	const provider = 'local';

	vi.mocked(useEnv).mockReturnValue({
		[`AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`]: 'http://example.com',
		PUBLIC_URL: 'http://example.com',
	});

	expect(isLoginRedirectAllowed('//example.com/admin/content', provider)).toBe(false);
	expect(isLoginRedirectAllowed('//user@password:example.com/', provider)).toBe(false);
});
import { useEnv } from '@directus/env';
import { toArray } from '@directus/utils';
import isUrlAllowed from './is-url-allowed.js';

/**
 * Checks if the defined redirect after successful SSO login is in the allow list
 */
export function isLoginRedirectAllowed(redirect: unknown, provider: string): boolean {
	if (!redirect) return true; // empty redirect
	if (typeof redirect !== 'string') return false; // invalid type

	const env = useEnv();
	const publicUrl = env['PUBLIC_URL'] as string;

	if (URL.canParse(redirect) === false) {
		if (redirect.startsWith('//') === false) {
			// should be a relative path like `/admin/test`
			return true;
		}

		// domain without protocol `//example.com/test`
		return false;
	}

	const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);

	const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;

	if (envKey in env) {
		if (isUrlAllowed(redirect, [...toArray(env[envKey] as string), publicUrl])) return true;
	}

	if (URL.canParse(publicUrl) === false) {
		return false;
	}

	// allow redirects to the defined PUBLIC_URL
	const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);

	return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
}
import { expect, test } from 'vitest';
import isUrlAllowed from './is-url-allowed.js';

test('isUrlAllowed should allow matching domain', () => {
	const checkUrl = 'https://directus.io';
	const allowedUrls = ['https://directus.io/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
});

test('isUrlAllowed should allow matching path', () => {
	const checkUrl = 'https://directus.io/tv';
	const allowedUrls = ['https://directus.io/tv'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(true);
});

test('isUrlAllowed should block different paths', () => {
	const checkUrl = 'http://example.com/test1';
	const allowedUrls = ['http://example.com/test2', 'http://example.com/test3', 'http://example.com/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});

test('isUrlAllowed should block different domains', () => {
	const checkUrl = 'http://directus.io/';
	const allowedUrls = ['http://example.com/', 'http://directus.chat'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});

test('isUrlAllowed blocks varying protocols', () => {
	const checkUrl = 'http://example.com/';
	const allowedUrls = ['ftp://example.com/', 'https://example.com/'];

	expect(isUrlAllowed(checkUrl, allowedUrls)).toBe(false);
});
 * Check if URL matches allow list either exactly or by origin (protocol+domain+port) + pathname
				const { origin, pathname } = new URL(allowedURL);
				return origin + pathname;
		const { origin, pathname } = new URL(url);
		return parsedWhitelist.includes(origin + pathname);
### Introduced Allow List for OAuth2/OpenID/SAML Redirects

Due to an Open Redirect vulnerability with the OAuth2, OpenID and SAML SSO providers, we have introduced an allow list
for these redirects.

If your current workflow depends on redirecting to an external domain after successful SSO login using the
`?redirect=http://example.com/login` query parameter, then you'll need to add this URL to the
`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` config option.

`AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST` accepts a comma-separated list of URLs (path is included in comparison).

| Variable                                    | Description                                                                                               | Default Value    |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the OAuth provider.                                                                 | --               |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the OAuth provider.                                                                     | --               |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `email`          |
| `AUTH_<PROVIDER>_AUTHORIZE_URL`             | Authorization page URL of the OAuth provider.                                                             | --               |
| `AUTH_<PROVIDER>_ACCESS_URL`                | Access token URL of the OAuth provider.                                                                   | --               |
| `AUTH_<PROVIDER>_PROFILE_URL`               | User profile URL of the OAuth provider.                                                                   | --               |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | --               |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `email`          |
| `AUTH_<PROVIDER>_FIRST_NAME_KEY`            | User profile first name key.                                                                              | --               |
| `AUTH_<PROVIDER>_LAST_NAME_KEY`             | User profile last name key.                                                                               | --               |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`          |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --               |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle` |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`     |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --               |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --               |
| Variable                                    | Description                                                                                               | Default Value          |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------- |
| `AUTH_<PROVIDER>_CLIENT_ID`                 | Client identifier for the external service.                                                               | --                     |
| `AUTH_<PROVIDER>_CLIENT_SECRET`             | Client secret for the external service.                                                                   | --                     |
| `AUTH_<PROVIDER>_SCOPE`                     | A white-space separated list of permissions to request.                                                   | `openid profile email` |
| `AUTH_<PROVIDER>_ISSUER_URL`                | OpenID `.well-known` discovery document URL of the external service.                                      | --                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>. Will default to `EMAIL_KEY`.                                  | `sub`<sup>[2]</sup>    |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                |
| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL`    | Require created users to have a verified email address.                                                   | `false`                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                     |
| `AUTH_<PROVIDER>_ICON`                      | SVG icon to display with the login link. [See options here](/user-guide/overview/glossary#icons).         | `account_circle`       |
| `AUTH_<PROVIDER>_LABEL`                     | Text to be presented on SSO button within App.                                                            | `<PROVIDER>`           |
| `AUTH_<PROVIDER>_PARAMS`                    | Custom query parameters applied to the authorization URL.                                                 | --                     |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                     |
| Variable                                    | Description                                                                                               | Default Value                                                          |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `AUTH_<PROVIDER>_SP_metadata`               | String containing XML metadata for service provider                                                       | --                                                                     |
| `AUTH_<PROVIDER>_IDP_metadata`              | String containing XML metadata for identity provider                                                      | --                                                                     |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Automatically create accounts for authenticating users.                                                   | `false`                                                                |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID`           | A Directus role ID to assign created users.                                                               | --                                                                     |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY`            | User profile identifier key <sup>[1]</sup>.                                                               | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
| `AUTH_<PROVIDER>_EMAIL_KEY`                 | User profile email key.                                                                                   | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`   |
| `AUTH_<PROVIDER>_GIVEN_NAME_KEY`            | User first name attribute.                                                                                | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`      |
| `AUTH_<PROVIDER>_FAMILY_NAME_KEY`           | User last name attribute.                                                                                 | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`        |
| `AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST`       | A comma-separated list of external URLs (including paths) allowed for redirecting after successful login. | --                                                                     |
	'AUTH_.+_REDIRECT_ALLOW_LIST',