feat(core): Refactor and add SAML preferences for service provider instance (#5637)

* create SP through parameters instead of metadata

* refactor SAML prefs and add SP configurations
This commit is contained in:
Michael Auerswald 2023-03-09 09:08:23 +01:00 committed by GitHub
parent 89d25995c3
commit 6f27b445ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 105 additions and 76 deletions

View file

@ -23,7 +23,7 @@ samlControllerProtected.get(
SamlUrls.config, SamlUrls.config,
samlLicensedOwnerMiddleware, samlLicensedOwnerMiddleware,
(req: SamlConfiguration.Read, res: express.Response) => { (req: SamlConfiguration.Read, res: express.Response) => {
const prefs = SamlService.getInstance().getSamlPreferences(); const prefs = SamlService.getInstance().samlPreferences;
return res.send(prefs); return res.send(prefs);
}, },
); );

View file

@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { SamlUrls } from '../constants'; import { SamlUrls } from '../constants';
import { getServiceProviderInstance } from '../serviceProvider.ee'; import { SamlService } from '../saml.service.ee';
/** /**
* SSO Endpoints that are public * SSO Endpoints that are public
@ -13,5 +13,7 @@ export const samlControllerPublic = express.Router();
* Return Service Provider metadata * Return Service Provider metadata
*/ */
samlControllerPublic.get(SamlUrls.metadata, async (req: express.Request, res: express.Response) => { samlControllerPublic.get(SamlUrls.metadata, async (req: express.Request, res: express.Response) => {
return res.header('Content-Type', 'text/xml').send(getServiceProviderInstance().getMetadata()); return res
.header('Content-Type', 'text/xml')
.send(SamlService.getInstance().getServiceProviderInstance().getMetadata());
}); });

View file

@ -5,11 +5,10 @@ import { jsonParse, LoggerProxy } from 'n8n-workflow';
import { AuthError, BadRequestError } from '@/ResponseHelper'; import { AuthError, BadRequestError } from '@/ResponseHelper';
import { getServiceProviderInstance } from './serviceProvider.ee'; import { getServiceProviderInstance } from './serviceProvider.ee';
import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { SamlUserAttributes } from './types/samlUserAttributes';
import type { SamlAttributeMapping } from './types/samlAttributeMapping';
import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers'; 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 } from 'samlify'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import { IdentityProvider, setSchemaValidator } from 'samlify'; import { IdentityProvider, setSchemaValidator } from 'samlify';
import { import {
createUserFromSamlAttributes, createUserFromSamlAttributes,
@ -32,30 +31,33 @@ export class SamlService {
private identityProviderInstance: IdentityProviderInstance | undefined; private identityProviderInstance: IdentityProviderInstance | undefined;
private _attributeMapping: SamlAttributeMapping = { private _samlPreferences: SamlPreferences = {
mapping: {
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname',
lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname',
userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
},
metadata: '',
metadataUrl: '',
ignoreSSL: false,
loginBinding: 'redirect',
acsBinding: 'post',
authnRequestsSigned: false,
loginEnabled: false,
loginLabel: 'SAML',
wantAssertionsSigned: true,
wantMessageSigned: true,
}; };
public get attributeMapping(): SamlAttributeMapping { public get samlPreferences(): SamlPreferences {
return this._attributeMapping; return {
...this._samlPreferences,
loginEnabled: isSamlLoginEnabled(),
loginLabel: getSamlLoginLabel(),
};
} }
public set attributeMapping(mapping: SamlAttributeMapping) {
// TODO:SAML: add validation
this._attributeMapping = mapping;
}
private metadata = '';
private metadataUrl = '';
private ignoreSSL = false;
private loginBinding: SamlLoginBinding = 'post';
static getInstance(): SamlService { static getInstance(): SamlService {
if (!SamlService.instance) { if (!SamlService.instance) {
SamlService.instance = new SamlService(); SamlService.instance = new SamlService();
@ -79,18 +81,22 @@ export class SamlService {
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
if (this.identityProviderInstance === undefined || forceRecreate) { if (this.identityProviderInstance === undefined || forceRecreate) {
this.identityProviderInstance = IdentityProvider({ this.identityProviderInstance = IdentityProvider({
metadata: this.metadata, metadata: this._samlPreferences.metadata,
}); });
} }
return this.identityProviderInstance; return this.identityProviderInstance;
} }
getServiceProviderInstance(): ServiceProviderInstance {
return getServiceProviderInstance(this._samlPreferences);
}
getLoginRequestUrl(binding?: SamlLoginBinding): { getLoginRequestUrl(binding?: SamlLoginBinding): {
binding: SamlLoginBinding; binding: SamlLoginBinding;
context: BindingContext | PostBindingContext; context: BindingContext | PostBindingContext;
} { } {
if (binding === undefined) binding = this.loginBinding; if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect';
if (binding === 'post') { if (binding === 'post') {
return { return {
binding, binding,
@ -105,7 +111,7 @@ export class SamlService {
} }
private getRedirectLoginRequestUrl(): BindingContext { private getRedirectLoginRequestUrl(): BindingContext {
const loginRequest = getServiceProviderInstance().createLoginRequest( const loginRequest = this.getServiceProviderInstance().createLoginRequest(
this.getIdentityProviderInstance(), this.getIdentityProviderInstance(),
'redirect', 'redirect',
); );
@ -115,7 +121,7 @@ export class SamlService {
} }
private getPostLoginRequestUrl(): PostBindingContext { private getPostLoginRequestUrl(): PostBindingContext {
const loginRequest = getServiceProviderInstance().createLoginRequest( const loginRequest = this.getServiceProviderInstance().createLoginRequest(
this.getIdentityProviderInstance(), this.getIdentityProviderInstance(),
'post', 'post',
) as PostBindingContext; ) as PostBindingContext;
@ -177,35 +183,30 @@ export class SamlService {
return undefined; return undefined;
} }
getSamlPreferences(): SamlPreferences {
return {
mapping: this.attributeMapping,
metadata: this.metadata,
metadataUrl: this.metadataUrl,
ignoreSSL: this.ignoreSSL,
loginBinding: this.loginBinding,
loginEnabled: isSamlLoginEnabled(),
loginLabel: getSamlLoginLabel(),
};
}
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> { async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
this.loginBinding = prefs.loginBinding ?? this.loginBinding; this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding;
this.metadata = prefs.metadata ?? this.metadata; this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata;
this.attributeMapping = prefs.mapping ?? this.attributeMapping; this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping;
this.ignoreSSL = prefs.ignoreSSL ?? this.ignoreSSL; this._samlPreferences.ignoreSSL = prefs.ignoreSSL ?? this._samlPreferences.ignoreSSL;
this._samlPreferences.acsBinding = prefs.acsBinding ?? this._samlPreferences.acsBinding;
this._samlPreferences.authnRequestsSigned =
prefs.authnRequestsSigned ?? this._samlPreferences.authnRequestsSigned;
this._samlPreferences.wantAssertionsSigned =
prefs.wantAssertionsSigned ?? this._samlPreferences.wantAssertionsSigned;
this._samlPreferences.wantMessageSigned =
prefs.wantMessageSigned ?? this._samlPreferences.wantMessageSigned;
if (prefs.metadataUrl) { if (prefs.metadataUrl) {
this.metadataUrl = prefs.metadataUrl; this._samlPreferences.metadataUrl = prefs.metadataUrl;
const fetchedMetadata = await this.fetchMetadataFromUrl(); const fetchedMetadata = await this.fetchMetadataFromUrl();
if (fetchedMetadata) { if (fetchedMetadata) {
this.metadata = fetchedMetadata; this._samlPreferences.metadata = fetchedMetadata;
} }
} else if (prefs.metadata) { } else if (prefs.metadata) {
const validationResult = await validateMetadata(prefs.metadata); const validationResult = await validateMetadata(prefs.metadata);
if (!validationResult) { if (!validationResult) {
throw new Error('Invalid SAML metadata'); throw new Error('Invalid SAML metadata');
} }
this.metadata = prefs.metadata; this._samlPreferences.metadata = prefs.metadata;
} }
setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
@ -232,7 +233,7 @@ export class SamlService {
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 },
}); });
const settingsValue = JSON.stringify(this.getSamlPreferences()); const settingsValue = JSON.stringify(this.samlPreferences);
let result: Settings; let result: Settings;
if (samlPreferences) { if (samlPreferences) {
samlPreferences.value = settingsValue; samlPreferences.value = settingsValue;
@ -249,25 +250,29 @@ export class SamlService {
} }
async fetchMetadataFromUrl(): Promise<string | undefined> { async fetchMetadataFromUrl(): Promise<string | undefined> {
if (!this._samlPreferences.metadataUrl)
throw new BadRequestError('Error fetching SAML Metadata, no Metadata URL set');
try { try {
// TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity) // TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity)
const agent = new https.Agent({ const agent = new https.Agent({
rejectUnauthorized: !this.ignoreSSL, rejectUnauthorized: !this._samlPreferences.ignoreSSL,
}); });
const response = await axios.get(this.metadataUrl, { httpsAgent: agent }); const response = await axios.get(this._samlPreferences.metadataUrl, { httpsAgent: agent });
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
const xml = (await response.data) as string; const xml = (await response.data) as string;
const validationResult = await validateMetadata(xml); const validationResult = await validateMetadata(xml);
if (!validationResult) { if (!validationResult) {
throw new BadRequestError( throw new BadRequestError(
`Data received from ${this.metadataUrl} is not valid SAML metadata.`, `Data received from ${this._samlPreferences.metadataUrl} is not valid SAML metadata.`,
); );
} }
return xml; return xml;
} }
} catch (error) { } catch (error) {
throw new BadRequestError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new BadRequestError(`Error fetching SAML Metadata from ${this.metadataUrl}: ${error}`); `Error fetching SAML Metadata from ${this._samlPreferences.metadataUrl}: ${error}`,
);
} }
return; return;
} }
@ -277,19 +282,21 @@ export class SamlService {
binding: SamlLoginBinding, binding: SamlLoginBinding,
): Promise<SamlUserAttributes> { ): Promise<SamlUserAttributes> {
let parsedSamlResponse; let parsedSamlResponse;
if (!this._samlPreferences.mapping)
throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set');
try { try {
parsedSamlResponse = await getServiceProviderInstance().parseLoginResponse( parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse(
this.getIdentityProviderInstance(), this.getIdentityProviderInstance(),
binding, binding,
req, req,
); );
} catch (error) { } catch (error) {
throw error; // throw error;
// throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); throw new AuthError('SAML Authentication failed. Could not parse SAML response.');
} }
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
parsedSamlResponse, parsedSamlResponse,
this.attributeMapping, this._samlPreferences.mapping,
); );
if (!attributes) { if (!attributes) {
throw new AuthError('SAML Authentication failed. Invalid SAML response.'); throw new AuthError('SAML Authentication failed. Invalid SAML response.');
@ -308,7 +315,7 @@ export class SamlService {
try { try {
// TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity) // TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity)
const agent = new https.Agent({ const agent = new https.Agent({
rejectUnauthorized: !this.ignoreSSL, rejectUnauthorized: !this._samlPreferences.ignoreSSL,
}); });
const requestContext = this.getLoginRequestUrl(); const requestContext = this.getLoginRequestUrl();
if (!requestContext) return false; if (!requestContext) return false;

View file

@ -1,29 +1,33 @@
/* 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 { ServiceProvider } from 'samlify';
import { SamlUrls } from './constants'; import { SamlUrls } from './constants';
import type { SamlPreferences } from './types/samlPreferences';
let serviceProviderInstance: ServiceProviderInstance | undefined; let serviceProviderInstance: ServiceProviderInstance | undefined;
const metadata = ` // TODO:SAML: make these configurable for the end user
<EntityDescriptor export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance {
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="${getInstanceBaseUrl() + SamlUrls.restMetadata}">
<SPSSODescriptor WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService isDefault="true" index="0" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${
getInstanceBaseUrl() + SamlUrls.restAcs
}"/>
</SPSSODescriptor>
</EntityDescriptor>
`;
export function getServiceProviderInstance(): ServiceProviderInstance {
if (serviceProviderInstance === undefined) { if (serviceProviderInstance === undefined) {
serviceProviderInstance = ServiceProvider({ serviceProviderInstance = ServiceProvider({
metadata, entityID: getInstanceBaseUrl() + SamlUrls.restMetadata,
authnRequestsSigned: prefs.authnRequestsSigned,
wantAssertionsSigned: prefs.wantAssertionsSigned,
wantMessageSigned: prefs.wantMessageSigned,
nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'],
assertionConsumerService: [
{
isDefault: prefs.acsBinding === 'post',
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
Location: getInstanceBaseUrl() + SamlUrls.restAcs,
},
{
isDefault: prefs.acsBinding === 'redirect',
Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-REDIRECT',
Location: getInstanceBaseUrl() + SamlUrls.restAcs,
},
],
}); });
} }

View file

@ -30,4 +30,20 @@ export class SamlPreferences {
@IsString() @IsString()
@IsOptional() @IsOptional()
loginLabel?: string; loginLabel?: string;
@IsBoolean()
@IsOptional()
authnRequestsSigned?: boolean = false;
@IsBoolean()
@IsOptional()
wantAssertionsSigned?: boolean = true;
@IsBoolean()
@IsOptional()
wantMessageSigned?: boolean = true;
@IsString()
@IsOptional()
acsBinding?: SamlLoginBinding = 'post';
} }