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) {
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') {

View file

@ -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<void> {
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<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.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<SamlPreferences | undefined> {
async loadFromDbAndApplySamlPreferences(apply = true): Promise<SamlPreferences | undefined> {
const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
if (samlPreferences) {
const prefs = jsonParse<SamlPreferences>(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<string | undefined> {
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<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 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<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',
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<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> {
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<boolean> {
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<boolean> {
export async function validateResponse(response: string): Promise<boolean> {
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<boolean> {
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

View file

@ -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,