mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
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:
parent
89d25995c3
commit
6f27b445ca
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
|
|
|
@ -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<SamlPreferences | undefined> {
|
||||
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<string | undefined> {
|
||||
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<SamlUserAttributes> {
|
||||
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;
|
||||
|
|
|
@ -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 = `
|
||||
<EntityDescriptor
|
||||
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 {
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue