fix(core): Load SAML libraries dynamically (#6690)

load SAML dynamically
This commit is contained in:
Michael Auerswald 2023-07-18 16:01:56 +02:00 committed by GitHub
parent 667c15d0df
commit fce5609fa3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 136 additions and 107 deletions

View file

@ -184,7 +184,7 @@ export class SamlController {
} }
private async handleInitSSO(res: express.Response, relayState?: string) { 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') { if (result?.binding === 'redirect') {
return result.context.context; return result.context.context;
} else if (result?.binding === 'post') { } else if (result?.binding === 'post') {

View file

@ -10,11 +10,12 @@ import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers';
import type { SamlPreferences } from './types/samlPreferences'; import type { SamlPreferences } from './types/samlPreferences';
import { SAML_PREFERENCES_DB_KEY } from './constants'; import { SAML_PREFERENCES_DB_KEY } from './constants';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import { IdentityProvider, setSchemaValidator } from 'samlify'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import { import {
createUserFromSamlAttributes, createUserFromSamlAttributes,
getMappedSamlAttributesFromFlowResult, getMappedSamlAttributesFromFlowResult,
getSamlLoginLabel, getSamlLoginLabel,
isSamlLicensedAndEnabled,
isSamlLoginEnabled, isSamlLoginEnabled,
setSamlLoginEnabled, setSamlLoginEnabled,
setSamlLoginLabel, setSamlLoginLabel,
@ -24,7 +25,6 @@ import type { Settings } from '@db/entities/Settings';
import axios from 'axios'; import axios from 'axios';
import https from 'https'; import https from 'https';
import type { SamlLoginBinding } from './types'; import type { SamlLoginBinding } from './types';
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import { validateMetadata, validateResponse } from './samlValidator'; import { validateMetadata, validateResponse } from './samlValidator';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
@ -32,6 +32,9 @@ import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
export class SamlService { export class SamlService {
private identityProviderInstance: IdentityProviderInstance | undefined; private identityProviderInstance: IdentityProviderInstance | undefined;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private samlify: typeof import('samlify') | undefined;
private _samlPreferences: SamlPreferences = { private _samlPreferences: SamlPreferences = {
mapping: { mapping: {
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
@ -68,8 +71,20 @@ export class SamlService {
} }
async init(): Promise<void> { async init(): Promise<void> {
await this.loadFromDbAndApplySamlPreferences(); // load preferences first but do not apply so as to not load samlify unnecessarily
setSchemaValidator({ 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) => { validate: async (response: string) => {
const valid = await validateResponse(response); const valid = await validateResponse(response);
if (!valid) { if (!valid) {
@ -80,8 +95,11 @@ export class SamlService {
} }
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
if (this.samlify === undefined) {
throw new Error('Samlify is not initialized');
}
if (this.identityProviderInstance === undefined || forceRecreate) { if (this.identityProviderInstance === undefined || forceRecreate) {
this.identityProviderInstance = IdentityProvider({ this.identityProviderInstance = this.samlify.IdentityProvider({
metadata: this._samlPreferences.metadata, metadata: this._samlPreferences.metadata,
}); });
} }
@ -90,16 +108,20 @@ export class SamlService {
} }
getServiceProviderInstance(): ServiceProviderInstance { 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, relayState?: string,
binding?: SamlLoginBinding, binding?: SamlLoginBinding,
): { ): Promise<{
binding: SamlLoginBinding; binding: SamlLoginBinding;
context: BindingContext | PostBindingContext; context: BindingContext | PostBindingContext;
} { }> {
await this.loadSamlify();
if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect'; if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect';
if (binding === 'post') { if (binding === 'post') {
return { return {
@ -192,6 +214,25 @@ export class SamlService {
} }
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> { async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
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.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
@ -207,34 +248,27 @@ export class SamlService {
prefs.wantMessageSigned ?? this._samlPreferences.wantMessageSigned; prefs.wantMessageSigned ?? this._samlPreferences.wantMessageSigned;
if (prefs.metadataUrl) { if (prefs.metadataUrl) {
this._samlPreferences.metadataUrl = prefs.metadataUrl; this._samlPreferences.metadataUrl = prefs.metadataUrl;
const fetchedMetadata = await this.fetchMetadataFromUrl();
if (fetchedMetadata) {
this._samlPreferences.metadata = fetchedMetadata;
}
} else if (prefs.metadata) { } else if (prefs.metadata) {
// remove metadataUrl if metadata is set directly // remove metadataUrl if metadata is set directly
this._samlPreferences.metadataUrl = undefined; this._samlPreferences.metadataUrl = undefined;
const validationResult = await validateMetadata(prefs.metadata);
if (!validationResult) {
throw new Error('Invalid SAML metadata');
}
this._samlPreferences.metadata = prefs.metadata; this._samlPreferences.metadata = prefs.metadata;
} }
await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
this.getIdentityProviderInstance(true);
const result = await this.saveSamlPreferencesToDb();
return result;
} }
async loadFromDbAndApplySamlPreferences(): Promise<SamlPreferences | undefined> { async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
const samlPreferences = await Db.collections.Settings.findOne({ const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY }, where: { key: SAML_PREFERENCES_DB_KEY },
}); });
if (samlPreferences) { if (samlPreferences) {
const prefs = jsonParse<SamlPreferences>(samlPreferences.value); const prefs = jsonParse<SamlPreferences>(samlPreferences.value);
if (prefs) { if (prefs) {
if (apply) {
await this.setSamlPreferences(prefs); await this.setSamlPreferences(prefs);
} else {
await this.loadPreferencesWithoutValidation(prefs);
}
return prefs; return prefs;
} }
} }
@ -262,6 +296,7 @@ export class SamlService {
} }
async fetchMetadataFromUrl(): Promise<string | undefined> { async fetchMetadataFromUrl(): Promise<string | undefined> {
await this.loadSamlify();
if (!this._samlPreferences.metadataUrl) if (!this._samlPreferences.metadataUrl)
throw new BadRequestError('Error fetching SAML Metadata, no Metadata URL set'); throw new BadRequestError('Error fetching SAML Metadata, no Metadata URL set');
try { try {
@ -297,6 +332,7 @@ export class SamlService {
if (!this._samlPreferences.mapping) if (!this._samlPreferences.mapping)
throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set'); throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set');
try { try {
await this.loadSamlify();
parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse( parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse(
this.getIdentityProviderInstance(), this.getIdentityProviderInstance(),
binding, binding,
@ -324,46 +360,4 @@ export class SamlService {
} }
return attributes; return attributes;
} }
async testSamlConnection(): Promise<boolean> {
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;
}
} }

View file

@ -1,46 +1,76 @@
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import type { XMLFileInfo } from 'xmllint-wasm'; 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 = { let xml: XMLFileInfo;
let xmldsigCore: XMLFileInfo;
let xmlXenc: XMLFileInfo;
let xmlMetadata: XMLFileInfo;
let xmlAssertion: XMLFileInfo;
let xmlProtocol: XMLFileInfo;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
let xmllintWasm: typeof import('xmllint-wasm') | undefined;
// dynamically load schema files
async function loadSchemas(): Promise<void> {
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', fileName: 'xml.xsd',
contents: xsdXml, contents: f.xsdXml,
}; };
}
const xmldsigCore: XMLFileInfo = { if (!xmldsigCore || xmldsigCore.contents === '') {
const f = await import('./schema/xmldsig-core-schema.xsd');
xmldsigCore = {
fileName: 'xmldsig-core-schema.xsd', fileName: 'xmldsig-core-schema.xsd',
contents: xsdXmldsigCore, contents: f.xsdXmldsigCore,
}; };
}
const xmlXenc: XMLFileInfo = { if (!xmlXenc || xmlXenc.contents === '') {
const f = await import('./schema/xenc-schema.xsd');
xmlXenc = {
fileName: 'xenc-schema.xsd', fileName: 'xenc-schema.xsd',
contents: xsdXenc, contents: f.xsdXenc,
}; };
}
const xmlMetadata: XMLFileInfo = { if (!xmlMetadata || xmlMetadata.contents === '') {
const f = await import('./schema/saml-schema-metadata-2.0.xsd');
xmlMetadata = {
fileName: 'saml-schema-metadata-2.0.xsd', fileName: 'saml-schema-metadata-2.0.xsd',
contents: xsdSamlSchemaMetadata20, contents: f.xsdSamlSchemaMetadata20,
}; };
}
const xmlAssertion: XMLFileInfo = { if (!xmlAssertion || xmlAssertion.contents === '') {
const f = await import('./schema/saml-schema-assertion-2.0.xsd');
xmlAssertion = {
fileName: 'saml-schema-assertion-2.0.xsd', fileName: 'saml-schema-assertion-2.0.xsd',
contents: xsdSamlSchemaAssertion20, contents: f.xsdSamlSchemaAssertion20,
}; };
}
const xmlProtocol: XMLFileInfo = { if (!xmlProtocol || xmlProtocol.contents === '') {
const f = await import('./schema/saml-schema-protocol-2.0.xsd');
xmlProtocol = {
fileName: 'saml-schema-protocol-2.0.xsd', fileName: 'saml-schema-protocol-2.0.xsd',
contents: xsdSamlSchemaProtocol20, contents: f.xsdSamlSchemaProtocol20,
}; };
}
}
// dynamically load xmllint-wasm
async function loadXmllintWasm(): Promise<void> {
if (xmllintWasm === undefined) {
LoggerProxy.debug('Loading xmllint-wasm library into memory');
xmllintWasm = await import('xmllint-wasm');
}
}
export async function validateMetadata(metadata: string): Promise<boolean> { export async function validateMetadata(metadata: string): Promise<boolean> {
try { try {
const validationResult = await validateXML({ await loadXmllintWasm();
await loadSchemas();
const validationResult = await xmllintWasm?.validateXML({
xml: [ xml: [
{ {
fileName: 'metadata.xml', fileName: 'metadata.xml',
@ -51,12 +81,12 @@ export async function validateMetadata(metadata: string): Promise<boolean> {
schema: [xmlMetadata], schema: [xmlMetadata],
preload: [xmlProtocol, xmlAssertion, xmldsigCore, xmlXenc, xml], preload: [xmlProtocol, xmlAssertion, xmldsigCore, xmlXenc, xml],
}); });
if (validationResult.valid) { if (validationResult?.valid) {
LoggerProxy.debug('SAML Metadata is valid'); LoggerProxy.debug('SAML Metadata is valid');
return true; return true;
} else { } else {
LoggerProxy.warn('SAML Validate Metadata: Invalid metadata'); LoggerProxy.warn('SAML Validate Metadata: Invalid metadata');
LoggerProxy.warn(validationResult.errors.join('\n')); LoggerProxy.warn(validationResult ? validationResult.errors.join('\n') : '');
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
@ -67,7 +97,9 @@ export async function validateMetadata(metadata: string): Promise<boolean> {
export async function validateResponse(response: string): Promise<boolean> { export async function validateResponse(response: string): Promise<boolean> {
try { try {
const validationResult = await validateXML({ await loadXmllintWasm();
await loadSchemas();
const validationResult = await xmllintWasm?.validateXML({
xml: [ xml: [
{ {
fileName: 'response.xml', fileName: 'response.xml',
@ -78,12 +110,12 @@ export async function validateResponse(response: string): Promise<boolean> {
schema: [xmlProtocol], schema: [xmlProtocol],
preload: [xmlMetadata, xmlAssertion, xmldsigCore, xmlXenc, xml], preload: [xmlMetadata, xmlAssertion, xmldsigCore, xmlXenc, xml],
}); });
if (validationResult.valid) { if (validationResult?.valid) {
LoggerProxy.debug('SAML Response is valid'); LoggerProxy.debug('SAML Response is valid');
return true; return true;
} else { } else {
LoggerProxy.warn('SAML Validate Response: Failed'); LoggerProxy.warn('SAML Validate Response: Failed');
LoggerProxy.warn(validationResult.errors.join('\n')); LoggerProxy.warn(validationResult ? validationResult.errors.join('\n') : '');
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { ServiceProviderInstance } from 'samlify'; import type { ServiceProviderInstance } from 'samlify';
import { ServiceProvider } from 'samlify';
import { SamlUrls } from './constants'; import { SamlUrls } from './constants';
import type { SamlPreferences } from './types/samlPreferences'; import type { SamlPreferences } from './types/samlPreferences';
@ -20,9 +19,13 @@ export function getServiceProviderConfigTestReturnUrl(): string {
} }
// TODO:SAML: make these configurable for the end user // 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) { if (serviceProviderInstance === undefined) {
serviceProviderInstance = ServiceProvider({ serviceProviderInstance = samlify.ServiceProvider({
entityID: getServiceProviderEntityId(), entityID: getServiceProviderEntityId(),
authnRequestsSigned: prefs.authnRequestsSigned, authnRequestsSigned: prefs.authnRequestsSigned,
wantAssertionsSigned: prefs.wantAssertionsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned,