RocketChat/Rocket.Chat

View on GitHub
apps/meteor/app/meteor-accounts-saml/server/lib/Utils.ts

Summary

Maintainability
F
3 days
Test Coverage
import { EventEmitter } from 'events';
import zlib from 'zlib';

import type { Logger } from '@rocket.chat/logger';

import { ensureArray } from '../../../../lib/utils/arrayUtils';
import type { IUserDataMap, IAttributeMapping } from '../definition/IAttributeMapping';
import type { ISAMLGlobalSettings } from '../definition/ISAMLGlobalSettings';
import type { ISAMLUser } from '../definition/ISAMLUser';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
import { StatusCode } from './constants';

let providerList: Array<IServiceProviderOptions> = [];
let debug = false;
let relayState: string | null = null;
let logger: Logger | undefined;

const globalSettings: ISAMLGlobalSettings = {
    generateUsername: false,
    nameOverwrite: false,
    mailOverwrite: false,
    immutableProperty: 'EMail',
    defaultUserRole: 'user',
    userDataFieldMap: '{"username":"username", "email":"email", "cn": "name"}',
    usernameNormalize: 'None',
    channelsAttributeUpdate: false,
    includePrivateChannelsInUpdate: false,
};

export class SAMLUtils {
    public static events: EventEmitter;

    public static get isDebugging(): boolean {
        return debug;
    }

    public static get globalSettings(): ISAMLGlobalSettings {
        return globalSettings;
    }

    public static get serviceProviders(): Array<IServiceProviderOptions> {
        return providerList;
    }

    public static get relayState(): string | null {
        return relayState;
    }

    public static set relayState(value: string | null) {
        relayState = value;
    }

    public static getServiceProviderOptions(providerName: string): IServiceProviderOptions | undefined {
        this.log(providerName, providerList);

        return providerList.find((providerOptions) => providerOptions.provider === providerName);
    }

    public static setServiceProvidersList(list: Array<IServiceProviderOptions>): void {
        providerList = list;
    }

    public static setLoggerInstance(instance: Logger): void {
        logger = instance;
    }

    // TODO: Some of those should probably not be global
    public static updateGlobalSettings(samlConfigs: Record<string, any>): void {
        debug = Boolean(samlConfigs.debug);

        globalSettings.generateUsername = Boolean(samlConfigs.generateUsername);
        globalSettings.nameOverwrite = Boolean(samlConfigs.nameOverwrite);
        globalSettings.mailOverwrite = Boolean(samlConfigs.mailOverwrite);
        globalSettings.channelsAttributeUpdate = Boolean(samlConfigs.channelsAttributeUpdate);
        globalSettings.includePrivateChannelsInUpdate = Boolean(samlConfigs.includePrivateChannelsInUpdate);

        if (samlConfigs.immutableProperty && typeof samlConfigs.immutableProperty === 'string') {
            globalSettings.immutableProperty = samlConfigs.immutableProperty;
        }

        if (samlConfigs.usernameNormalize && typeof samlConfigs.usernameNormalize === 'string') {
            globalSettings.usernameNormalize = samlConfigs.usernameNormalize;
        }

        if (samlConfigs.defaultUserRole && typeof samlConfigs.defaultUserRole === 'string') {
            globalSettings.defaultUserRole = samlConfigs.defaultUserRole;
        }

        if (samlConfigs.userDataFieldMap && typeof samlConfigs.userDataFieldMap === 'string') {
            globalSettings.userDataFieldMap = samlConfigs.userDataFieldMap;
        }
    }

    public static generateUniqueID(): string {
        const chars = 'abcdef0123456789';
        let uniqueID = 'id-';
        for (let i = 0; i < 20; i++) {
            uniqueID += chars.substr(Math.floor(Math.random() * 15), 1);
        }
        return uniqueID;
    }

    public static generateInstant(): string {
        return new Date().toISOString();
    }

    public static certToPEM(cert: string): string {
        const lines = cert.match(/.{1,64}/g);
        if (!lines) {
            throw new Error('Invalid Certificate');
        }

        lines.splice(0, 0, '-----BEGIN CERTIFICATE-----');
        lines.push('-----END CERTIFICATE-----');

        return lines.join('\n');
    }

    public static fillTemplateData(template: string, data: Record<string, string>): string {
        let newTemplate = template;

        for (const variable in data) {
            if (variable in data) {
                const key = `__${variable}__`;
                while (newTemplate.includes(key)) {
                    newTemplate = newTemplate.replace(key, data[variable]);
                }
            }
        }

        return newTemplate;
    }

    public static getValidationActionRedirectPath(credentialToken: string, redirectUrl?: string): string {
        const redirectUrlParam = redirectUrl ? `&redirectUrl=${encodeURIComponent(redirectUrl)}` : '';
        // the saml_idp_credentialToken param is needed by the mobile app
        return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}${redirectUrlParam}`;
    }

    public static log(obj: any, ...args: Array<any>): void {
        if (debug && logger) {
            logger.debug(obj, ...args);
        }
    }

    public static error(obj: any, ...args: Array<any>): void {
        if (logger) {
            logger.error(obj, ...args);
        }
    }

    public static async inflateXml(
        base64Data: string,
        successCallback: (xml: string) => Promise<void>,
        errorCallback: (err: string | object | null) => Promise<void>,
    ): Promise<void> {
        return new Promise((resolve, reject) => {
            const buffer = Buffer.from(base64Data, 'base64');
            zlib.inflateRaw(buffer, (err, decoded) => {
                if (err) {
                    this.log(`Error while inflating. ${err}`);
                    return reject(errorCallback(err));
                }

                if (!decoded) {
                    return reject(errorCallback('Failed to extract request data'));
                }

                const xmlString = this.convertArrayBufferToString(decoded);
                return resolve(successCallback(xmlString));
            });
        });
    }

    public static validateStatus(doc: Document): {
        success: boolean;
        message: string;
        statusCode: string;
    } {
        let successStatus = false;
        let status = null;
        let messageText = '';

        const statusNodes = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusCode');

        if (statusNodes.length) {
            const statusNode = statusNodes[0];
            const statusMessage = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'StatusMessage')[0];

            if (statusMessage?.firstChild?.textContent) {
                messageText = statusMessage.firstChild.textContent;
            }

            status = statusNode.getAttribute('Value');

            if (status === StatusCode.success) {
                successStatus = true;
            }
        }
        return {
            success: successStatus,
            message: messageText,
            statusCode: status || '',
        };
    }

    public static normalizeCert(cert: string): string {
        if (!cert) {
            return cert;
        }

        return cert
            .replace(/-+BEGIN CERTIFICATE-+\r?\n?/, '')
            .replace(/-+END CERTIFICATE-+\r?\n?/, '')
            .replace(/\r\n/g, '\n')
            .trim();
    }

    public static getUserDataMapping(): IUserDataMap {
        const { userDataFieldMap, immutableProperty } = globalSettings;

        let map: Record<string, any>;

        try {
            map = JSON.parse(userDataFieldMap);
        } catch (e) {
            SAMLUtils.log(userDataFieldMap);
            SAMLUtils.log(e);
            throw new Error('Failed to parse custom user field map');
        }

        const parsedMap: IUserDataMap = {
            attributeList: new Set(),
            email: {
                fieldName: 'email',
            },
            username: {
                fieldName: 'username',
            },
            name: {
                fieldName: 'cn',
            },
            identifier: {
                type: '',
            },
        };

        let identifier = immutableProperty.toLowerCase();

        for (const spFieldName in map) {
            if (!map.hasOwnProperty(spFieldName)) {
                continue;
            }

            const attribute = map[spFieldName];
            if (typeof attribute !== 'string' && typeof attribute !== 'object') {
                throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`);
            }

            if (spFieldName === '__identifier__') {
                if (typeof attribute !== 'string') {
                    throw new Error('SAML User Map: Invalid identifier.');
                }

                identifier = attribute;
                continue;
            }

            let attributeMap: IAttributeMapping | null = null;

            // If it's a complex type, let's check what's in it
            if (typeof attribute === 'object') {
                // A fieldName is mandatory for complex fields. If it's missing, let's skip this one.
                if (!attribute.hasOwnProperty('fieldName') && !attribute.hasOwnProperty('fieldNames')) {
                    continue;
                }

                const fieldName = attribute.fieldName || attribute.fieldNames;
                const { regex, template } = attribute;

                if (Array.isArray(fieldName)) {
                    if (!fieldName.length) {
                        throw new Error(`SAML User Map: Invalid configuration for ${spFieldName} field.`);
                    }

                    for (const idpFieldName of fieldName) {
                        parsedMap.attributeList.add(idpFieldName);
                    }
                } else {
                    parsedMap.attributeList.add(fieldName);
                }

                if (regex && typeof regex !== 'string') {
                    throw new Error('SAML User Map: Invalid RegEx');
                }

                if (template && typeof template !== 'string') {
                    throw new Error('SAML User Map: Invalid Template');
                }

                attributeMap = {
                    fieldName,
                    ...(regex && { regex }),
                    ...(template && { template }),
                };
            } else if (typeof attribute === 'string') {
                attributeMap = {
                    fieldName: attribute,
                };
                parsedMap.attributeList.add(attribute);
            }

            if (attributeMap) {
                if (spFieldName === 'email' || spFieldName === 'username' || spFieldName === 'name') {
                    parsedMap[spFieldName] = attributeMap;
                }
            }
        }

        if (identifier) {
            const defaultTypes = ['email', 'username'];

            if (defaultTypes.includes(identifier)) {
                parsedMap.identifier.type = identifier;
            } else {
                parsedMap.identifier.type = 'custom';
                parsedMap.identifier.attribute = identifier;
                parsedMap.attributeList.add(identifier);
            }
        }
        return parsedMap;
    }

    public static getProfileValue(profile: Record<string, any>, mapping: IAttributeMapping, forceString = false): any {
        const values: Record<string, string> = {
            regex: '',
        };
        const fieldNames = ensureArray<string>(mapping.fieldName);

        let mainValue;
        for (const fieldName of fieldNames) {
            let profileValue = profile[fieldName];

            if (Array.isArray(profileValue)) {
                for (let i = 0; i < profile[fieldName].length; i++) {
                    // Add every index to the list of possible values to be used, both first to last and from last to first
                    values[`${fieldName}[${i}]`] = profileValue[i];
                    values[`${fieldName}[-${Math.abs(0 - profileValue.length + i)}]`] = profileValue[i];
                }
                values[`${fieldName}[]`] = profileValue.join(' ');
                if (forceString) {
                    profileValue = profileValue.join(' ');
                }
            } else {
                values[fieldName] = profileValue;
            }

            values[fieldName] = profileValue;

            if (!mainValue) {
                mainValue = profileValue;
            }
        }

        let shouldRunTemplate = false;
        if (typeof mapping.template === 'string') {
            // unless the regex result is used on the template, we process the template first
            if (mapping.template.includes('__regex__')) {
                shouldRunTemplate = true;
            } else {
                mainValue = this.fillTemplateData(mapping.template, values);
            }
        }

        if (mapping.regex && mainValue && mainValue.match) {
            let regexValue;
            const match = mainValue.match(new RegExp(mapping.regex));
            if (match?.length) {
                if (match.length >= 2) {
                    regexValue = match[1];
                } else {
                    regexValue = match[0];
                }
            }

            if (regexValue) {
                values.regex = regexValue;
                if (!shouldRunTemplate) {
                    mainValue = regexValue;
                }
            }
        }

        if (shouldRunTemplate && typeof mapping.template === 'string') {
            mainValue = this.fillTemplateData(mapping.template, values);
        }

        return mainValue;
    }

    public static convertArrayBufferToString(buffer: ArrayBuffer, encoding: BufferEncoding = 'utf8'): string {
        return Buffer.from(buffer).toString(encoding);
    }

    public static normalizeUsername(name: string): string {
        const { globalSettings } = this;

        switch (globalSettings.usernameNormalize) {
            case 'Lowercase':
                name = name.toLowerCase();
                break;
        }

        return name;
    }

    public static mapProfileToUserObject(profile: Record<string, any>): ISAMLUser {
        const userDataMap = this.getUserDataMapping();
        SAMLUtils.log('parsed userDataMap', userDataMap);

        if (userDataMap.identifier.type === 'custom') {
            if (!userDataMap.identifier.attribute) {
                throw new Error('SAML User Data Map: invalid Identifier configuration received.');
            }
            if (!profile[userDataMap.identifier.attribute]) {
                throw new Error(`SAML Profile did not have the expected identifier (${userDataMap.identifier.attribute}).`);
            }
        }

        const attributeList = new Map();
        for (const attributeName of userDataMap.attributeList) {
            if (profile[attributeName] === undefined) {
                this.log(`SAML user profile is missing the attribute ${attributeName}.`);
                continue;
            }
            attributeList.set(attributeName, profile[attributeName]);
        }
        const email = this.getProfileValue(profile, userDataMap.email);
        const profileUsername = this.getProfileValue(profile, userDataMap.username, true);
        const name = this.getProfileValue(profile, userDataMap.name, true);

        // Even if we're not using the email to identify the user, it is still mandatory because it's a mandatory information on Rocket.Chat
        if (!email) {
            throw new Error('SAML Profile did not contain an email address');
        }

        const userObject: ISAMLUser = {
            samlLogin: {
                provider: this.relayState,
                idp: profile.issuer,
                idpSession: profile.sessionIndex,
                nameID: profile.nameID,
            },
            emailList: ensureArray<string>(email),
            fullName: name || profile.displayName || profile.username,
            eppn: profile.eppn,
            attributeList,
            identifier: userDataMap.identifier,
        };

        if (profileUsername) {
            userObject.username = this.normalizeUsername(profileUsername);
        }

        if (profile.language) {
            userObject.language = profile.language;
        }

        if (profile.channels) {
            if (Array.isArray(profile.channels)) {
                userObject.channels = profile.channels;
            } else {
                userObject.channels = profile.channels.split(',');
            }
        }

        this.events.emit('mapUser', { profile, userObject });

        return userObject;
    }
}

SAMLUtils.events = new EventEmitter();