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 47e625817c..8f142d7484 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 @@ -23,7 +23,7 @@ samlControllerProtected.get( SamlUrls.config, samlLicensedOwnerMiddleware, (req: SamlConfiguration.Read, res: express.Response) => { - const prefs = SamlService.getInstance().getSamlPreferences(); + const prefs = SamlService.getInstance().samlPreferences; return res.send(prefs); }, ); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts index 33e9427f70..999b7f2df2 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts @@ -1,6 +1,6 @@ import express from 'express'; import { SamlUrls } from '../constants'; -import { getServiceProviderInstance } from '../serviceProvider.ee'; +import { SamlService } from '../saml.service.ee'; /** * SSO Endpoints that are public @@ -13,5 +13,7 @@ export const samlControllerPublic = express.Router(); * Return Service Provider metadata */ 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()); }); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index b204c1686b..523b904f0d 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -5,11 +5,10 @@ import { jsonParse, LoggerProxy } from 'n8n-workflow'; import { AuthError, BadRequestError } from '@/ResponseHelper'; import { getServiceProviderInstance } from './serviceProvider.ee'; import type { SamlUserAttributes } from './types/samlUserAttributes'; -import type { SamlAttributeMapping } from './types/samlAttributeMapping'; import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers'; import type { SamlPreferences } from './types/samlPreferences'; import { SAML_PREFERENCES_DB_KEY } from './constants'; -import type { IdentityProviderInstance } from 'samlify'; +import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; import { IdentityProvider, setSchemaValidator } from 'samlify'; import { createUserFromSamlAttributes, @@ -32,30 +31,33 @@ export class SamlService { private identityProviderInstance: IdentityProviderInstance | undefined; - private _attributeMapping: SamlAttributeMapping = { - email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', - firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', - lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', - userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + private _samlPreferences: SamlPreferences = { + mapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', + lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', + 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 { - return this._attributeMapping; + public get samlPreferences(): SamlPreferences { + 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 { if (!SamlService.instance) { SamlService.instance = new SamlService(); @@ -79,18 +81,22 @@ export class SamlService { getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance { if (this.identityProviderInstance === undefined || forceRecreate) { this.identityProviderInstance = IdentityProvider({ - metadata: this.metadata, + metadata: this._samlPreferences.metadata, }); } return this.identityProviderInstance; } + getServiceProviderInstance(): ServiceProviderInstance { + return getServiceProviderInstance(this._samlPreferences); + } + getLoginRequestUrl(binding?: SamlLoginBinding): { binding: SamlLoginBinding; context: BindingContext | PostBindingContext; } { - if (binding === undefined) binding = this.loginBinding; + if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect'; if (binding === 'post') { return { binding, @@ -105,7 +111,7 @@ export class SamlService { } private getRedirectLoginRequestUrl(): BindingContext { - const loginRequest = getServiceProviderInstance().createLoginRequest( + const loginRequest = this.getServiceProviderInstance().createLoginRequest( this.getIdentityProviderInstance(), 'redirect', ); @@ -115,7 +121,7 @@ export class SamlService { } private getPostLoginRequestUrl(): PostBindingContext { - const loginRequest = getServiceProviderInstance().createLoginRequest( + const loginRequest = this.getServiceProviderInstance().createLoginRequest( this.getIdentityProviderInstance(), 'post', ) as PostBindingContext; @@ -177,35 +183,30 @@ export class SamlService { 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 { - this.loginBinding = prefs.loginBinding ?? this.loginBinding; - this.metadata = prefs.metadata ?? this.metadata; - this.attributeMapping = prefs.mapping ?? this.attributeMapping; - this.ignoreSSL = prefs.ignoreSSL ?? this.ignoreSSL; + this._samlPreferences.loginBinding = prefs.loginBinding ?? this._samlPreferences.loginBinding; + this._samlPreferences.metadata = prefs.metadata ?? this._samlPreferences.metadata; + this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; + 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) { - this.metadataUrl = prefs.metadataUrl; + this._samlPreferences.metadataUrl = prefs.metadataUrl; const fetchedMetadata = await this.fetchMetadataFromUrl(); if (fetchedMetadata) { - this.metadata = fetchedMetadata; + this._samlPreferences.metadata = fetchedMetadata; } } else if (prefs.metadata) { const validationResult = await validateMetadata(prefs.metadata); if (!validationResult) { throw new Error('Invalid SAML metadata'); } - this.metadata = prefs.metadata; + this._samlPreferences.metadata = prefs.metadata; } setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); @@ -232,7 +233,7 @@ export class SamlService { const samlPreferences = await Db.collections.Settings.findOne({ where: { key: SAML_PREFERENCES_DB_KEY }, }); - const settingsValue = JSON.stringify(this.getSamlPreferences()); + const settingsValue = JSON.stringify(this.samlPreferences); let result: Settings; if (samlPreferences) { samlPreferences.value = settingsValue; @@ -249,25 +250,29 @@ export class SamlService { } async fetchMetadataFromUrl(): Promise { + if (!this._samlPreferences.metadataUrl) + throw new BadRequestError('Error fetching SAML Metadata, no Metadata URL set'); try { // TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity) 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) { const xml = (await response.data) as string; const validationResult = await validateMetadata(xml); if (!validationResult) { 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; } } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new BadRequestError(`Error fetching SAML Metadata from ${this.metadataUrl}: ${error}`); + throw new BadRequestError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Error fetching SAML Metadata from ${this._samlPreferences.metadataUrl}: ${error}`, + ); } return; } @@ -277,19 +282,21 @@ export class SamlService { binding: SamlLoginBinding, ): Promise { let parsedSamlResponse; + if (!this._samlPreferences.mapping) + throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set'); try { - parsedSamlResponse = await getServiceProviderInstance().parseLoginResponse( + parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse( this.getIdentityProviderInstance(), binding, req, ); } catch (error) { - throw error; - // throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); + // throw error; + throw new AuthError('SAML Authentication failed. Could not parse SAML response.'); } const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult( parsedSamlResponse, - this.attributeMapping, + this._samlPreferences.mapping, ); if (!attributes) { throw new AuthError('SAML Authentication failed. Invalid SAML response.'); @@ -308,7 +315,7 @@ export class SamlService { try { // TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity) const agent = new https.Agent({ - rejectUnauthorized: !this.ignoreSSL, + rejectUnauthorized: !this._samlPreferences.ignoreSSL, }); const requestContext = this.getLoginRequestUrl(); if (!requestContext) return false; diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index 020ab83ed9..796d64a343 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -1,29 +1,33 @@ +/* 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'; let serviceProviderInstance: ServiceProviderInstance | undefined; -const metadata = ` - - - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress - - - -`; - -export function getServiceProviderInstance(): ServiceProviderInstance { +// TODO:SAML: make these configurable for the end user +export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { if (serviceProviderInstance === undefined) { 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, + }, + ], }); } diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index 8adc4311c8..d013d0ed4e 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -30,4 +30,20 @@ export class SamlPreferences { @IsString() @IsOptional() loginLabel?: string; + + @IsBoolean() + @IsOptional() + authnRequestsSigned?: boolean = false; + + @IsBoolean() + @IsOptional() + wantAssertionsSigned?: boolean = true; + + @IsBoolean() + @IsOptional() + wantMessageSigned?: boolean = true; + + @IsString() + @IsOptional() + acsBinding?: SamlLoginBinding = 'post'; }