From 523fa71705c0408c6c60c7cb5135323e3488e8c9 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Fri, 3 Mar 2023 10:19:43 +0100 Subject: [PATCH] feat(core): Add SAML post and test endpoints (#5595) * consolidate SSO settings * update saml settings * fix type error * limit user changes when saml is enabled * add test * add toggle endpoint and fetch metadata * rename enabled param * add handling of POST saml login request * add config test endpoint --- packages/cli/src/sso/saml/constants.ts | 4 + .../routes/saml.controller.protected.ee.ts | 51 ++++++- packages/cli/src/sso/saml/saml.service.ee.ts | 140 +++++++++++++++--- packages/cli/src/sso/saml/types/index.ts | 1 + packages/cli/src/sso/saml/types/requests.ts | 1 + .../cli/src/sso/saml/types/samlPreferences.ts | 33 ++++- .../cli/src/sso/saml/views/initSsoPost.ts | 15 ++ .../cli/src/sso/saml/views/initSsoRedirect.ts | 12 ++ 8 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 packages/cli/src/sso/saml/types/index.ts create mode 100644 packages/cli/src/sso/saml/views/initSsoPost.ts create mode 100644 packages/cli/src/sso/saml/views/initSsoRedirect.ts diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts index 715c20b932c98..6f92690f5f5d1 100644 --- a/packages/cli/src/sso/saml/constants.ts +++ b/packages/cli/src/sso/saml/constants.ts @@ -15,6 +15,10 @@ export class SamlUrls { static readonly config = '/config'; + static readonly configTest = '/config/test'; + + static readonly configToggleEnabled = '/config/toggle'; + static readonly restConfig = this.samlRESTRoot + this.config; static readonly defaultRedirect = '/'; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts index 5a4376f692e8a..9a98e6926bd0f 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts @@ -8,7 +8,10 @@ import { SamlUrls } from '../constants'; import type { SamlConfiguration } from '../types/requests'; import { AuthError, BadRequestError } from '@/ResponseHelper'; import { issueCookie } from '../../../auth/jwt'; -import { isSamlPreferences } from '../samlHelpers'; +import { validate } from 'class-validator'; +import type { PostBindingContext } from 'samlify/types/src/entity'; +import { getInitSSOFormView } from '../views/initSsoPost'; +import { getInitSSOPostView } from '../views/initSsoRedirect'; export const samlControllerProtected = express.Router(); @@ -27,17 +30,38 @@ samlControllerProtected.get( /** * POST /sso/saml/config - * Return SAML config + * Set SAML config */ samlControllerProtected.post( SamlUrls.config, samlLicensedOwnerMiddleware, async (req: SamlConfiguration.Update, res: express.Response) => { - if (isSamlPreferences(req.body)) { + const validationResult = await validate(req.body); + if (validationResult.length === 0) { const result = await SamlService.getInstance().setSamlPreferences(req.body); return res.send(result); } else { - throw new BadRequestError('Body is not a SamlPreferences object'); + throw new BadRequestError( + 'Body is not a valid SamlPreferences object: ' + + validationResult.map((e) => e.toString()).join(','), + ); + } + }, +); + +/** + * POST /sso/saml/config/toggle + * Set SAML config + */ +samlControllerProtected.post( + SamlUrls.configToggleEnabled, + samlLicensedOwnerMiddleware, + async (req: SamlConfiguration.Toggle, res: express.Response) => { + if (req.body.loginEnabled !== undefined) { + await SamlService.getInstance().setSamlPreferences({ loginEnabled: req.body.loginEnabled }); + res.sendStatus(200); + } else { + throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); } }, ); @@ -96,12 +120,23 @@ samlControllerProtected.get( SamlUrls.initSSO, samlLicensedAndEnabledMiddleware, async (req: express.Request, res: express.Response) => { - const url = SamlService.getInstance().getRedirectLoginRequestUrl(); - if (url) { - // TODO:SAML: redirect to the URL on the client side - return res.status(301).send(url); + const result = SamlService.getInstance().getLoginRequestUrl(); + if (result?.binding === 'redirect') { + // forced client side redirect + return res.send(getInitSSOPostView(result.context)); + // return res.status(301).send(result.context.context); + } else if (result?.binding === 'post') { + return res.send(getInitSSOFormView(result.context as PostBindingContext)); } else { throw new AuthError('SAML redirect failed, please check your SAML configuration.'); } }, ); + +samlControllerProtected.get( + SamlUrls.configTest, + async (req: express.Request, res: express.Response) => { + const testResult = await SamlService.getInstance().testSamlConnection(); + return res.send(testResult); + }, +); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 72dc3bf2c2c12..d4fd3b40ba99a 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -2,7 +2,7 @@ import type express from 'express'; import * as Db from '@/Db'; import type { User } from '@/databases/entities/User'; import { jsonParse, LoggerProxy } from 'n8n-workflow'; -import { AuthError } from '@/ResponseHelper'; +import { AuthError, BadRequestError } from '@/ResponseHelper'; import { getServiceProviderInstance } from './serviceProvider.ee'; import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { SamlAttributeMapping } from './types/samlAttributeMapping'; @@ -20,6 +20,10 @@ import { setSamlLoginLabel, updateUserFromSamlAttributes, } from './samlHelpers'; +import type { Settings } from '../../databases/entities/Settings'; +import axios from 'axios'; +import type { SamlLoginBinding } from './types'; +import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; export class SamlService { private static instance: SamlService; @@ -44,6 +48,10 @@ export class SamlService { private _metadata = ''; + private metadataUrl = ''; + + private loginBinding: SamlLoginBinding = 'post'; + public get metadata(): string { return this._metadata; } @@ -53,7 +61,7 @@ export class SamlService { } constructor() { - this.loadSamlPreferences() + this.loadFromDbAndApplySamlPreferences() .then(() => { LoggerProxy.debug('Initializing SAML service'); }) @@ -70,7 +78,7 @@ export class SamlService { } async init(): Promise { - await this.loadSamlPreferences(); + await this.loadFromDbAndApplySamlPreferences(); } getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { @@ -83,19 +91,48 @@ export class SamlService { return this.identityProviderInstance; } - getRedirectLoginRequestUrl(): string { + getLoginRequestUrl(binding?: SamlLoginBinding): { + binding: SamlLoginBinding; + context: BindingContext | PostBindingContext; + } { + if (binding === undefined) binding = this.loginBinding; + if (binding === 'post') { + return { + binding, + context: this.getPostLoginRequestUrl(), + }; + } else { + return { + binding, + context: this.getRedirectLoginRequestUrl(), + }; + } + } + + private getRedirectLoginRequestUrl(): BindingContext { const loginRequest = getServiceProviderInstance().createLoginRequest( this.getIdentityProviderInstance(), 'redirect', ); //TODO:SAML: debug logging LoggerProxy.debug(loginRequest.context); - return loginRequest.context; + return loginRequest; + } + + private getPostLoginRequestUrl(): PostBindingContext { + const loginRequest = getServiceProviderInstance().createLoginRequest( + this.getIdentityProviderInstance(), + 'post', + ) as PostBindingContext; + //TODO:SAML: debug logging + + LoggerProxy.debug(loginRequest.context); + return loginRequest; } async handleSamlLogin( req: express.Request, - binding: 'post' | 'redirect', + binding: SamlLoginBinding, ): Promise< | { authenticatedUser: User | undefined; @@ -150,21 +187,32 @@ export class SamlService { return { mapping: this.attributeMapping, metadata: this.metadata, + metadataUrl: this.metadataUrl, + loginBinding: this.loginBinding, loginEnabled: isSamlLoginEnabled(), loginLabel: getSamlLoginLabel(), }; } - async setSamlPreferences(prefs: SamlPreferences): Promise { - this.attributeMapping = prefs.mapping; - this.metadata = prefs.metadata; - setSamlLoginEnabled(prefs.loginEnabled); - setSamlLoginLabel(prefs.loginLabel); + async setSamlPreferences(prefs: SamlPreferences): Promise { + this.loginBinding = prefs.loginBinding ?? this.loginBinding; + this.metadata = prefs.metadata ?? this.metadata; + this.attributeMapping = prefs.mapping ?? this.attributeMapping; + if (prefs.metadataUrl) { + this.metadataUrl = prefs.metadataUrl; + const fetchedMetadata = await this.fetchMetadataFromUrl(); + if (fetchedMetadata) { + this.metadata = fetchedMetadata; + } + } + setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); + setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); this.getIdentityProviderInstance(true); - await this.saveSamlPreferences(); + const result = await this.saveSamlPreferencesToDb(); + return result; } - async loadSamlPreferences(): Promise { + async loadFromDbAndApplySamlPreferences(): Promise { const samlPreferences = await Db.collections.Settings.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); @@ -178,26 +226,47 @@ export class SamlService { return; } - async saveSamlPreferences(): Promise { + async saveSamlPreferencesToDb(): Promise { const samlPreferences = await Db.collections.Settings.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); const settingsValue = JSON.stringify(this.getSamlPreferences()); + let result: Settings; if (samlPreferences) { samlPreferences.value = settingsValue; - await Db.collections.Settings.save(samlPreferences); + result = await Db.collections.Settings.save(samlPreferences); } else { - await Db.collections.Settings.save({ + result = await Db.collections.Settings.save({ key: SAML_PREFERENCES_DB_KEY, value: settingsValue, loadOnStartup: true, }); } + if (result) return jsonParse(result.value); + return; + } + + async fetchMetadataFromUrl(): Promise { + try { + const prevRejectStatus = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const response = await axios.get(this.metadataUrl); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevRejectStatus; + if (response.status === 200 && response.data) { + const xml = (await response.data) as string; + // TODO: SAML: validate XML + // throw new BadRequestError('Received XML is not valid SAML metadata.'); + return xml; + } + } catch (error) { + throw new BadRequestError('SAML Metadata URL is invalid or response is .'); + } + return; } async getAttributesFromLoginResponse( req: express.Request, - binding: 'post' | 'redirect', + binding: SamlLoginBinding, ): Promise { let parsedSamlResponse; try { @@ -226,4 +295,41 @@ export class SamlService { } return attributes; } + + async testSamlConnection(): Promise { + try { + const requestContext = this.getLoginRequestUrl(); + if (!requestContext) return false; + if (requestContext.binding === 'redirect') { + const fetchResult = await axios.get(requestContext.context.context); + if (fetchResult.status !== 200) { + LoggerProxy.debug('SAML: Error while testing SAML connection.'); + return false; + } + } else if (requestContext.binding === 'post') { + const context = requestContext.context as PostBindingContext; + const endpoint = context.entityEndpoint; + const params = new URLSearchParams(); + params.append(context.type, context.context); + if (context.relayState) { + params.append('RelayState', context.relayState); + } + const fetchResult = await axios.post(endpoint, params, { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Content-type': 'application/x-www-form-urlencoded', + }, + }); + if (fetchResult.status !== 200) { + LoggerProxy.debug('SAML: Error while testing SAML connection.'); + return false; + } + } + return true; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + LoggerProxy.debug('SAML: Error while testing SAML connection: ', error); + } + return false; + } } diff --git a/packages/cli/src/sso/saml/types/index.ts b/packages/cli/src/sso/saml/types/index.ts new file mode 100644 index 0000000000000..560f7003f821a --- /dev/null +++ b/packages/cli/src/sso/saml/types/index.ts @@ -0,0 +1 @@ +export type SamlLoginBinding = 'post' | 'redirect'; diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts index c9beab0c21bdc..a095df7870147 100644 --- a/packages/cli/src/sso/saml/types/requests.ts +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -4,4 +4,5 @@ import type { SamlPreferences } from './samlPreferences'; export declare namespace SamlConfiguration { type Read = AuthenticatedRequest<{}, {}, {}, {}>; type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; + type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>; } diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index 4edc58fc95902..e43b588b819fb 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -1,8 +1,29 @@ -import type { SamlAttributeMapping } from './samlAttributeMapping'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { SamlLoginBinding } from '.'; +import { SamlAttributeMapping } from './samlAttributeMapping'; -export interface SamlPreferences { - mapping: SamlAttributeMapping; - metadata: string; - loginEnabled: boolean; - loginLabel: string; +export class SamlPreferences { + @IsObject() + @IsOptional() + mapping?: SamlAttributeMapping; + + @IsString() + @IsOptional() + metadata?: string; + + @IsString() + @IsOptional() + metadataUrl?: string; + + @IsString() + @IsOptional() + loginBinding?: SamlLoginBinding = 'redirect'; + + @IsBoolean() + @IsOptional() + loginEnabled?: boolean; + + @IsString() + @IsOptional() + loginLabel?: string; } diff --git a/packages/cli/src/sso/saml/views/initSsoPost.ts b/packages/cli/src/sso/saml/views/initSsoPost.ts new file mode 100644 index 0000000000000..4df6719efb13b --- /dev/null +++ b/packages/cli/src/sso/saml/views/initSsoPost.ts @@ -0,0 +1,15 @@ +import type { PostBindingContext } from 'samlify/types/src/entity'; + +export function getInitSSOFormView(context: PostBindingContext): string { + return ` +
+ + ${context.relayState ? '' : ''} +
+`; +} diff --git a/packages/cli/src/sso/saml/views/initSsoRedirect.ts b/packages/cli/src/sso/saml/views/initSsoRedirect.ts new file mode 100644 index 0000000000000..56db9ce0838d7 --- /dev/null +++ b/packages/cli/src/sso/saml/views/initSsoRedirect.ts @@ -0,0 +1,12 @@ +import type { BindingContext } from 'samlify/types/src/entity'; + +export function getInitSSOPostView(context: BindingContext): string { + return ` + + `; +}