Files
Automata/packages/cli/src/sso/saml/saml.service.ee.ts
Michael Auerswald 25fe14be56 fix(core): Remove SAML config metadataUrl if XML metadata is set directly (#6143)
remove metadataUrl if metadata is set directly
2023-05-02 11:22:34 +02:00

369 lines
12 KiB
TypeScript

import type express from 'express';
import { Service } from 'typedi';
import * as Db from '@/Db';
import type { User } from '@db/entities/User';
import { jsonParse, LoggerProxy } from 'n8n-workflow';
import { AuthError, BadRequestError } from '@/ResponseHelper';
import { getServiceProviderInstance } from './serviceProvider.ee';
import type { SamlUserAttributes } from './types/samlUserAttributes';
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 {
createUserFromSamlAttributes,
getMappedSamlAttributesFromFlowResult,
getSamlLoginLabel,
isSamlLoginEnabled,
setSamlLoginEnabled,
setSamlLoginLabel,
updateUserFromSamlAttributes,
} from './samlHelpers';
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';
@Service()
export class SamlService {
private identityProviderInstance: IdentityProviderInstance | undefined;
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,
relayState: getInstanceBaseUrl(),
signatureConfig: {
prefix: 'ds',
location: {
reference: '/samlp:Response/saml:Issuer',
action: 'after',
},
},
};
public get samlPreferences(): SamlPreferences {
return {
...this._samlPreferences,
loginEnabled: isSamlLoginEnabled(),
loginLabel: getSamlLoginLabel(),
};
}
async init(): Promise<void> {
await this.loadFromDbAndApplySamlPreferences();
setSchemaValidator({
validate: async (response: string) => {
const valid = await validateResponse(response);
if (!valid) {
throw new Error('Invalid SAML response');
}
},
});
}
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
if (this.identityProviderInstance === undefined || forceRecreate) {
this.identityProviderInstance = IdentityProvider({
metadata: this._samlPreferences.metadata,
});
}
return this.identityProviderInstance;
}
getServiceProviderInstance(): ServiceProviderInstance {
return getServiceProviderInstance(this._samlPreferences);
}
getLoginRequestUrl(
relayState?: string,
binding?: SamlLoginBinding,
): {
binding: SamlLoginBinding;
context: BindingContext | PostBindingContext;
} {
if (binding === undefined) binding = this._samlPreferences.loginBinding ?? 'redirect';
if (binding === 'post') {
return {
binding,
context: this.getPostLoginRequestUrl(relayState),
};
} else {
return {
binding,
context: this.getRedirectLoginRequestUrl(relayState),
};
}
}
private getRedirectLoginRequestUrl(relayState?: string): BindingContext {
const sp = this.getServiceProviderInstance();
sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect');
//TODO:SAML: debug logging
LoggerProxy.debug(loginRequest.context);
return loginRequest;
}
private getPostLoginRequestUrl(relayState?: string): PostBindingContext {
const sp = this.getServiceProviderInstance();
sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl();
const loginRequest = sp.createLoginRequest(
this.getIdentityProviderInstance(),
'post',
) as PostBindingContext;
//TODO:SAML: debug logging
LoggerProxy.debug(loginRequest.context);
return loginRequest;
}
async handleSamlLogin(
req: express.Request,
binding: SamlLoginBinding,
): Promise<{
authenticatedUser: User | undefined;
attributes: SamlUserAttributes;
onboardingRequired: boolean;
}> {
const attributes = await this.getAttributesFromLoginResponse(req, binding);
if (attributes.email) {
const user = await Db.collections.User.findOne({
where: { email: attributes.email },
relations: ['globalRole', 'authIdentities'],
});
if (user) {
// Login path for existing users that are fully set up and that have a SAML authIdentity set up
if (
user.authIdentities.find(
(e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName,
)
) {
return {
authenticatedUser: user,
attributes,
onboardingRequired: false,
};
} else {
// Login path for existing users that are NOT fully set up for SAML
const updatedUser = await updateUserFromSamlAttributes(user, attributes);
const onboardingRequired = !updatedUser.firstName || !updatedUser.lastName;
return {
authenticatedUser: updatedUser,
attributes,
onboardingRequired,
};
}
} else {
// New users to be created JIT based on SAML attributes
if (isSsoJustInTimeProvisioningEnabled()) {
const newUser = await createUserFromSamlAttributes(attributes);
return {
authenticatedUser: newUser,
attributes,
onboardingRequired: true,
};
}
}
}
return {
authenticatedUser: undefined,
attributes,
onboardingRequired: false,
};
}
async setSamlPreferences(prefs: SamlPreferences): Promise<SamlPreferences | undefined> {
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.signatureConfig =
prefs.signatureConfig ?? this._samlPreferences.signatureConfig;
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._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> {
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);
return prefs;
}
}
return;
}
async saveSamlPreferencesToDb(): Promise<SamlPreferences | undefined> {
const samlPreferences = await Db.collections.Settings.findOne({
where: { key: SAML_PREFERENCES_DB_KEY },
});
const settingsValue = JSON.stringify(this.samlPreferences);
let result: Settings;
if (samlPreferences) {
samlPreferences.value = settingsValue;
result = await Db.collections.Settings.save(samlPreferences);
} else {
result = await Db.collections.Settings.save({
key: SAML_PREFERENCES_DB_KEY,
value: settingsValue,
loadOnStartup: true,
});
}
if (result) return jsonParse<SamlPreferences>(result.value);
return;
}
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._samlPreferences.ignoreSSL,
});
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._samlPreferences.metadataUrl} is not valid SAML metadata.`,
);
}
return xml;
}
} catch (error) {
throw new BadRequestError(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Error fetching SAML Metadata from ${this._samlPreferences.metadataUrl}: ${error}`,
);
}
return;
}
async getAttributesFromLoginResponse(
req: express.Request,
binding: SamlLoginBinding,
): Promise<SamlUserAttributes> {
let parsedSamlResponse;
if (!this._samlPreferences.mapping)
throw new BadRequestError('Error fetching SAML Attributes, no Attribute mapping set');
try {
parsedSamlResponse = await this.getServiceProviderInstance().parseLoginResponse(
this.getIdentityProviderInstance(),
binding,
req,
);
} catch (error) {
// throw error;
throw new AuthError(
`SAML Authentication failed. Could not parse SAML response. ${(error as Error).message}`,
);
}
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
parsedSamlResponse,
this._samlPreferences.mapping,
);
if (!attributes) {
throw new AuthError('SAML Authentication failed. Invalid SAML response.');
}
if (!attributes.email && missingAttributes.length > 0) {
throw new AuthError(
`SAML Authentication failed. Invalid SAML response (missing attributes: ${missingAttributes.join(
', ',
)}).`,
);
}
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;
}
}