From fce5609fa32b81ff8e44567233c77a7a1d6232df Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Tue, 18 Jul 2023 16:01:56 +0200 Subject: [PATCH] fix(core): Load SAML libraries dynamically (#6690) load SAML dynamically --- .../src/sso/saml/routes/saml.controller.ee.ts | 2 +- packages/cli/src/sso/saml/saml.service.ee.ts | 122 +++++++++--------- packages/cli/src/sso/saml/samlValidator.ts | 110 ++++++++++------ .../cli/src/sso/saml/serviceProvider.ee.ts | 9 +- 4 files changed, 136 insertions(+), 107 deletions(-) diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index d0d27730f3..f7229f75d4 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -184,7 +184,7 @@ export class SamlController { } private async handleInitSSO(res: express.Response, relayState?: string) { - const result = this.samlService.getLoginRequestUrl(relayState); + const result = await this.samlService.getLoginRequestUrl(relayState); if (result?.binding === 'redirect') { return result.context.context; } else if (result?.binding === 'post') { diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index fa63646f9d..8e9eaad2cb 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -10,11 +10,12 @@ import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers'; import type { SamlPreferences } from './types/samlPreferences'; import { SAML_PREFERENCES_DB_KEY } from './constants'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; -import { IdentityProvider, setSchemaValidator } from 'samlify'; +import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import { createUserFromSamlAttributes, getMappedSamlAttributesFromFlowResult, getSamlLoginLabel, + isSamlLicensedAndEnabled, isSamlLoginEnabled, setSamlLoginEnabled, setSamlLoginLabel, @@ -24,7 +25,6 @@ import type { Settings } from '@db/entities/Settings'; import axios from 'axios'; import https from 'https'; import type { SamlLoginBinding } from './types'; -import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import { validateMetadata, validateResponse } from './samlValidator'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; @@ -32,6 +32,9 @@ import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; export class SamlService { private identityProviderInstance: IdentityProviderInstance | undefined; + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + private samlify: typeof import('samlify') | undefined; + private _samlPreferences: SamlPreferences = { mapping: { email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', @@ -68,8 +71,20 @@ export class SamlService { } async init(): Promise { - await this.loadFromDbAndApplySamlPreferences(); - setSchemaValidator({ + // load preferences first but do not apply so as to not load samlify unnecessarily + await this.loadFromDbAndApplySamlPreferences(false); + if (isSamlLicensedAndEnabled()) { + await this.loadSamlify(); + await this.loadFromDbAndApplySamlPreferences(true); + } + } + + async loadSamlify() { + if (this.samlify === undefined) { + LoggerProxy.debug('Loading samlify library into memory'); + this.samlify = await import('samlify'); + } + this.samlify.setSchemaValidator({ validate: async (response: string) => { const valid = await validateResponse(response); if (!valid) { @@ -80,8 +95,11 @@ export class SamlService { } getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { + if (this.samlify === undefined) { + throw new Error('Samlify is not initialized'); + } if (this.identityProviderInstance === undefined || forceRecreate) { - this.identityProviderInstance = IdentityProvider({ + this.identityProviderInstance = this.samlify.IdentityProvider({ metadata: this._samlPreferences.metadata, }); } @@ -90,16 +108,20 @@ export class SamlService { } getServiceProviderInstance(): ServiceProviderInstance { - return getServiceProviderInstance(this._samlPreferences); + if (this.samlify === undefined) { + throw new Error('Samlify is not initialized'); + } + return getServiceProviderInstance(this._samlPreferences, this.samlify); } - getLoginRequestUrl( + async getLoginRequestUrl( relayState?: string, binding?: SamlLoginBinding, - ): { + ): Promise<{ binding: SamlLoginBinding; context: BindingContext | PostBindingContext; - } { + }> { + await this.loadSamlify(); if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect'; if (binding === 'post') { return { @@ -192,6 +214,25 @@ export class SamlService { } async setSamlPreferences(prefs: SamlPreferences): Promise { + await this.loadSamlify(); + await this.loadPreferencesWithoutValidation(prefs); + if (prefs.metadataUrl) { + const fetchedMetadata = await this.fetchMetadataFromUrl(); + if (fetchedMetadata) { + this._samlPreferences.metadata = fetchedMetadata; + } + } else if (prefs.metadata) { + const validationResult = await validateMetadata(prefs.metadata); + if (!validationResult) { + throw new Error('Invalid SAML metadata'); + } + } + this.getIdentityProviderInstance(true); + const result = await this.saveSamlPreferencesToDb(); + return result; + } + + async loadPreferencesWithoutValidation(prefs: SamlPreferences) { this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; @@ -207,34 +248,27 @@ export class SamlService { prefs.wantMessageSigned ?? this._samlPreferences.wantMessageSigned; if (prefs.metadataUrl) { this._samlPreferences.metadataUrl = prefs.metadataUrl; - const fetchedMetadata = await this.fetchMetadataFromUrl(); - if (fetchedMetadata) { - this._samlPreferences.metadata = fetchedMetadata; - } } else if (prefs.metadata) { // remove metadataUrl if metadata is set directly this._samlPreferences.metadataUrl = undefined; - const validationResult = await validateMetadata(prefs.metadata); - if (!validationResult) { - throw new Error('Invalid SAML metadata'); - } this._samlPreferences.metadata = prefs.metadata; } await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); - this.getIdentityProviderInstance(true); - const result = await this.saveSamlPreferencesToDb(); - return result; } - async loadFromDbAndApplySamlPreferences(): Promise { + async loadFromDbAndApplySamlPreferences(apply = true): Promise { const samlPreferences = await Db.collections.Settings.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); if (samlPreferences) { const prefs = jsonParse(samlPreferences.value); if (prefs) { - await this.setSamlPreferences(prefs); + if (apply) { + await this.setSamlPreferences(prefs); + } else { + await this.loadPreferencesWithoutValidation(prefs); + } return prefs; } } @@ -262,6 +296,7 @@ export class SamlService { } async fetchMetadataFromUrl(): Promise { + await this.loadSamlify(); if (!this._samlPreferences.metadataUrl) throw new BadRequestError('Error fetching SAML Metadata, no Metadata URL set'); try { @@ -297,6 +332,7 @@ export class SamlService { if (!this._samlPreferences.mapping) throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set'); try { + await this.loadSamlify(); parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse( this.getIdentityProviderInstance(), binding, @@ -324,46 +360,4 @@ export class SamlService { } return attributes; } - - async testSamlConnection(): Promise { - try { - // TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity) - const agent = new https.Agent({ - rejectUnauthorized: !this._samlPreferences.ignoreSSL, - }); - const requestContext = this.getLoginRequestUrl(); - if (!requestContext) return false; - if (requestContext.binding === 'redirect') { - const fetchResult = await axios.get(requestContext.context.context, { httpsAgent: agent }); - 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', - }, - httpsAgent: agent, - }); - 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/samlValidator.ts b/packages/cli/src/sso/saml/samlValidator.ts index 2eaa3505ae..9c46effb69 100644 --- a/packages/cli/src/sso/saml/samlValidator.ts +++ b/packages/cli/src/sso/saml/samlValidator.ts @@ -1,46 +1,76 @@ import { LoggerProxy } from 'n8n-workflow'; import type { XMLFileInfo } from 'xmllint-wasm'; -import { validateXML } from 'xmllint-wasm'; -import { xsdSamlSchemaAssertion20 } from './schema/saml-schema-assertion-2.0.xsd'; -import { xsdSamlSchemaMetadata20 } from './schema/saml-schema-metadata-2.0.xsd'; -import { xsdSamlSchemaProtocol20 } from './schema/saml-schema-protocol-2.0.xsd'; -import { xsdXenc } from './schema/xenc-schema.xsd'; -import { xsdXml } from './schema/xml.xsd'; -import { xsdXmldsigCore } from './schema/xmldsig-core-schema.xsd'; -const xml: XMLFileInfo = { - fileName: 'xml.xsd', - contents: xsdXml, -}; +let xml: XMLFileInfo; +let xmldsigCore: XMLFileInfo; +let xmlXenc: XMLFileInfo; +let xmlMetadata: XMLFileInfo; +let xmlAssertion: XMLFileInfo; +let xmlProtocol: XMLFileInfo; -const xmldsigCore: XMLFileInfo = { - fileName: 'xmldsig-core-schema.xsd', - contents: xsdXmldsigCore, -}; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +let xmllintWasm: typeof import('xmllint-wasm') | undefined; -const xmlXenc: XMLFileInfo = { - fileName: 'xenc-schema.xsd', - contents: xsdXenc, -}; +// dynamically load schema files +async function loadSchemas(): Promise { + if (!xml || xml.contents === '') { + LoggerProxy.debug('Loading XML schema files for SAML validation into memory'); + const f = await import('./schema/xml.xsd'); + xml = { + fileName: 'xml.xsd', + contents: f.xsdXml, + }; + } + if (!xmldsigCore || xmldsigCore.contents === '') { + const f = await import('./schema/xmldsig-core-schema.xsd'); + xmldsigCore = { + fileName: 'xmldsig-core-schema.xsd', + contents: f.xsdXmldsigCore, + }; + } + if (!xmlXenc || xmlXenc.contents === '') { + const f = await import('./schema/xenc-schema.xsd'); + xmlXenc = { + fileName: 'xenc-schema.xsd', + contents: f.xsdXenc, + }; + } + if (!xmlMetadata || xmlMetadata.contents === '') { + const f = await import('./schema/saml-schema-metadata-2.0.xsd'); + xmlMetadata = { + fileName: 'saml-schema-metadata-2.0.xsd', + contents: f.xsdSamlSchemaMetadata20, + }; + } + if (!xmlAssertion || xmlAssertion.contents === '') { + const f = await import('./schema/saml-schema-assertion-2.0.xsd'); + xmlAssertion = { + fileName: 'saml-schema-assertion-2.0.xsd', + contents: f.xsdSamlSchemaAssertion20, + }; + } + if (!xmlProtocol || xmlProtocol.contents === '') { + const f = await import('./schema/saml-schema-protocol-2.0.xsd'); + xmlProtocol = { + fileName: 'saml-schema-protocol-2.0.xsd', + contents: f.xsdSamlSchemaProtocol20, + }; + } +} -const xmlMetadata: XMLFileInfo = { - fileName: 'saml-schema-metadata-2.0.xsd', - contents: xsdSamlSchemaMetadata20, -}; - -const xmlAssertion: XMLFileInfo = { - fileName: 'saml-schema-assertion-2.0.xsd', - contents: xsdSamlSchemaAssertion20, -}; - -const xmlProtocol: XMLFileInfo = { - fileName: 'saml-schema-protocol-2.0.xsd', - contents: xsdSamlSchemaProtocol20, -}; +// dynamically load xmllint-wasm +async function loadXmllintWasm(): Promise { + if (xmllintWasm === undefined) { + LoggerProxy.debug('Loading xmllint-wasm library into memory'); + xmllintWasm = await import('xmllint-wasm'); + } +} export async function validateMetadata(metadata: string): Promise { try { - const validationResult = await validateXML({ + await loadXmllintWasm(); + await loadSchemas(); + const validationResult = await xmllintWasm?.validateXML({ xml: [ { fileName: 'metadata.xml', @@ -51,12 +81,12 @@ export async function validateMetadata(metadata: string): Promise { schema: [xmlMetadata], preload: [xmlProtocol, xmlAssertion, xmldsigCore, xmlXenc, xml], }); - if (validationResult.valid) { + if (validationResult?.valid) { LoggerProxy.debug('SAML Metadata is valid'); return true; } else { LoggerProxy.warn('SAML Validate Metadata: Invalid metadata'); - LoggerProxy.warn(validationResult.errors.join('\n')); + LoggerProxy.warn(validationResult ? validationResult.errors.join('\n') : ''); } } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument @@ -67,7 +97,9 @@ export async function validateMetadata(metadata: string): Promise { export async function validateResponse(response: string): Promise { try { - const validationResult = await validateXML({ + await loadXmllintWasm(); + await loadSchemas(); + const validationResult = await xmllintWasm?.validateXML({ xml: [ { fileName: 'response.xml', @@ -78,12 +110,12 @@ export async function validateResponse(response: string): Promise { schema: [xmlProtocol], preload: [xmlMetadata, xmlAssertion, xmldsigCore, xmlXenc, xml], }); - if (validationResult.valid) { + if (validationResult?.valid) { LoggerProxy.debug('SAML Response is valid'); return true; } else { LoggerProxy.warn('SAML Validate Response: Failed'); - LoggerProxy.warn(validationResult.errors.join('\n')); + LoggerProxy.warn(validationResult ? validationResult.errors.join('\n') : ''); } } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index f6d707eaf8..fa1fde6283 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import type { ServiceProviderInstance } from 'samlify'; -import { ServiceProvider } from 'samlify'; import { SamlUrls } from './constants'; import type { SamlPreferences } from './types/samlPreferences'; @@ -20,9 +19,13 @@ export function getServiceProviderConfigTestReturnUrl(): string { } // TODO:SAML: make these configurable for the end user -export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { +export function getServiceProviderInstance( + prefs: SamlPreferences, + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + samlify: typeof import('samlify'), +): ServiceProviderInstance { if (serviceProviderInstance === undefined) { - serviceProviderInstance = ServiceProvider({ + serviceProviderInstance = samlify.ServiceProvider({ entityID: getServiceProviderEntityId(), authnRequestsSigned: prefs.authnRequestsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned,