RocketChat/Rocket.Chat

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

Summary

Maintainability
F
5 days
Test Coverage
import xmldom from '@xmldom/xmldom';
import xmlCrypto from 'xml-crypto';
import xmlenc from 'xml-encryption';

import type { ISAMLAssertion } from '../../definition/ISAMLAssertion';
import type { IServiceProviderOptions } from '../../definition/IServiceProviderOptions';
import type { IResponseValidateCallback } from '../../definition/callbacks';
import { SAMLUtils } from '../Utils';
import { StatusCode } from '../constants';

type XmlParent = Element | Document;

export class ResponseParser {
    serviceProviderOptions: IServiceProviderOptions;

    constructor(serviceProviderOptions: IServiceProviderOptions) {
        this.serviceProviderOptions = serviceProviderOptions;
    }

    public validate(xml: string, callback: IResponseValidateCallback): void {
        // We currently use RelayState to save SAML provider
        SAMLUtils.log(`Validating response with relay state: ${xml}`);

        let error: Error | null = null;

        const doc = new xmldom.DOMParser({
            errorHandler: {
                fatalError: (e: any) => {
                    if (e instanceof Error) {
                        error = e;
                        return;
                    }

                    if (typeof e === 'string') {
                        error = new Error(e);
                        return;
                    }

                    error = new Error();
                },
                error: (e: Error) => {
                    if (e instanceof Error) {
                        error = e;
                        return;
                    }

                    if (typeof e === 'string') {
                        error = new Error(e);
                        return;
                    }

                    error = new Error();
                },
            },
        }).parseFromString(xml, 'text/xml');
        if (!doc) {
            return callback('No Doc Found');
        }

        if (error) {
            return callback(error, null, false);
        }

        const allResponses = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'Response');
        if (allResponses.length === 0) {
            return this._checkLogoutResponse(doc, callback);
        }

        if (allResponses.length !== 1) {
            return callback(new Error('Too many SAML responses'), null, false);
        }
        const response = allResponses[0];
        SAMLUtils.log('Got response');

        SAMLUtils.log('Verify status');
        const statusValidateObj = SAMLUtils.validateStatus(doc);
        if (!statusValidateObj.success) {
            if (!statusValidateObj.statusCode) {
                return callback(new Error('Missing StatusCode'), null, false);
            }

            if (statusValidateObj.statusCode === StatusCode.responder && statusValidateObj.message) {
                return callback(new Error(statusValidateObj.message), null, false);
            }

            return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
        }
        SAMLUtils.log('Status ok');

        let assertion: XmlParent;
        let assertionData: ISAMLAssertion;
        let issuer;

        try {
            assertionData = this.getAssertion(response, xml);
            assertion = assertionData.assertion;

            this.verifySignatures(response, assertionData, xml);
        } catch (e) {
            return callback(e instanceof Error ? e : String(e), null, false);
        }

        const profile: Record<string, any> = {};

        if (response.hasAttribute('InResponseTo')) {
            profile.inResponseToId = response.getAttribute('InResponseTo');
        }

        try {
            issuer = this.getIssuer(assertion);
        } catch (e) {
            return callback(e instanceof Error ? e : String(e), null, false);
        }

        if (issuer) {
            profile.issuer = issuer.textContent;
        }

        const subject = this.getSubject(assertion);
        if (subject) {
            const nameID = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'NameID')[0];
            if (nameID) {
                profile.nameID = nameID.textContent;

                if (nameID.hasAttribute('Format')) {
                    profile.nameIDFormat = nameID.getAttribute('Format');
                }
            }

            try {
                this.validateSubjectConditions(subject);
            } catch (e) {
                return callback(e instanceof Error ? e : String(e), null, false);
            }
        }

        try {
            this.validateAssertionConditions(assertion);
        } catch (e) {
            return callback(e instanceof Error ? e : String(e), null, false);
        }

        const authnStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AuthnStatement')[0];

        if (authnStatement) {
            if (authnStatement.hasAttribute('SessionIndex')) {
                profile.sessionIndex = authnStatement.getAttribute('SessionIndex');
                SAMLUtils.log(`Session Index: ${profile.sessionIndex}`);
            } else {
                SAMLUtils.log('No Session Index Found');
            }
        } else {
            SAMLUtils.log('No AuthN Statement found');
        }

        const attributeStatement = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeStatement')[0];
        if (attributeStatement) {
            this.mapAttributes(attributeStatement, profile);
        } else {
            SAMLUtils.log('No Attribute Statement found in SAML response.');
        }

        if (!profile.email && profile.nameID && profile.nameIDFormat && profile.nameIDFormat.indexOf('emailAddress') >= 0) {
            profile.email = profile.nameID;
        }

        const profileKeys = Object.keys(profile);
        for (let i = 0; i < profileKeys.length; i++) {
            const key = profileKeys[i];

            if (key.match(/\./)) {
                profile[key.replace(/\./g, '-')] = profile[key];
                delete profile[key];
            }
        }

        SAMLUtils.log({ msg: 'NameID', profile });
        return callback(null, profile, false);
    }

    private _checkLogoutResponse(doc: Document, callback: IResponseValidateCallback): void {
        const logoutResponse = doc.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:protocol', 'LogoutResponse');
        if (!logoutResponse.length) {
            return callback(new Error('Unknown SAML response message'), null, false);
        }

        SAMLUtils.log('Verify status');
        const statusValidateObj = SAMLUtils.validateStatus(doc);
        if (!statusValidateObj.success) {
            return callback(new Error(`Status is: ${statusValidateObj.statusCode}`), null, false);
        }
        SAMLUtils.log('Status ok');

        // @ToDo: Check if this situation is still used
        return callback(null, null, true);
    }

    private getAssertion(response: Element, xml: string): ISAMLAssertion {
        const allAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
        const allEncrypedAssertions = response.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedAssertion');

        if (allAssertions.length + allEncrypedAssertions.length > 1) {
            throw new Error('Too many SAML assertions');
        }

        let assertion: XmlParent = allAssertions[0];
        const encAssertion = allEncrypedAssertions[0];
        let newXml = null;

        if (typeof encAssertion !== 'undefined') {
            const options = { key: this.serviceProviderOptions.privateKey };
            const encData = encAssertion.getElementsByTagNameNS('*', 'EncryptedData')[0];
            xmlenc.decrypt(encData, options, (err, result) => {
                if (err) {
                    SAMLUtils.error(err);
                }

                const document = new xmldom.DOMParser().parseFromString(result, 'text/xml');
                if (!document) {
                    throw new Error('Failed to decrypt SAML assertion');
                }

                const decryptedAssertions = document.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Assertion');
                if (decryptedAssertions.length) {
                    assertion = decryptedAssertions[0];
                }

                newXml = result;
            });
        }

        if (!assertion) {
            throw new Error('Missing SAML assertion');
        }

        return {
            assertion,
            xml: newXml || xml,
        };
    }

    private verifySignatures(response: Element, assertionData: ISAMLAssertion, xml: string): void {
        if (!this.serviceProviderOptions.cert) {
            return;
        }

        const signatureType = this.serviceProviderOptions.signatureValidationType;

        const checkEither = signatureType === 'Either';
        const checkResponse = signatureType === 'Response' || signatureType === 'All' || checkEither;
        const checkAssertion = signatureType === 'Assertion' || signatureType === 'All' || checkEither;
        let anyValidSignature = false;

        if (checkResponse) {
            SAMLUtils.log('Verify Document Signature');
            if (!this.validateResponseSignature(xml, this.serviceProviderOptions.cert, response)) {
                if (!checkEither) {
                    SAMLUtils.log('Document Signature WRONG');
                    throw new Error('Invalid Signature');
                }
            } else {
                anyValidSignature = true;
            }
            SAMLUtils.log('Document Signature OK');
        }

        if (checkAssertion) {
            SAMLUtils.log('Verify Assertion Signature');
            if (!this.validateAssertionSignature(assertionData.xml, this.serviceProviderOptions.cert, assertionData.assertion)) {
                if (!checkEither) {
                    SAMLUtils.log('Assertion Signature WRONG');
                    throw new Error('Invalid Assertion signature');
                }
            } else {
                anyValidSignature = true;
            }
            SAMLUtils.log('Assertion Signature OK');
        }

        if (checkEither && !anyValidSignature) {
            SAMLUtils.log('No Valid Signature');
            throw new Error('No valid SAML Signature found');
        }
    }

    private validateResponseSignature(xml: string, cert: string, response: Element): boolean {
        return this.validateSignatureChildren(xml, cert, response);
    }

    private validateAssertionSignature(xml: string, cert: string, assertion: XmlParent): boolean {
        return this.validateSignatureChildren(xml, cert, assertion);
    }

    private validateSignatureChildren(xml: string, cert: string, parent: XmlParent): boolean {
        const xpathSigQuery = ".//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']";
        const signatures = xmlCrypto.xpath(parent, xpathSigQuery) as Array<Element>;
        let signature = null;

        for (const sign of signatures) {
            if (sign.parentNode !== parent) {
                continue;
            }

            // Too many signatures
            if (signature) {
                SAMLUtils.log('Failed to validate SAML signature: Too Many Signatures');
                return false;
            }

            signature = sign;
        }

        if (!signature) {
            SAMLUtils.log('Failed to validate SAML signature: Signature not found');
            return false;
        }

        return this.validateSignature(xml, cert, signature);
    }

    private validateSignature(xml: string, cert: string, signature: Element): any {
        const sig = new xmlCrypto.SignedXml();

        sig.keyInfoProvider = {
            getKeyInfo: () => '<X509Data></X509Data>',
            getKey: () => Buffer.from(SAMLUtils.certToPEM(cert)),
        };

        sig.loadSignature(signature);

        const result = sig.checkSignature(xml);
        if (!result && sig.validationErrors) {
            SAMLUtils.log(sig.validationErrors);
        }

        return result;
    }

    private getIssuer(assertion: XmlParent): any {
        const issuers = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Issuer');
        if (issuers.length > 1) {
            throw new Error('Too many Issuers');
        }

        return issuers[0];
    }

    private getSubject(assertion: XmlParent): XmlParent {
        let subject: XmlParent = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Subject')[0];
        const encSubject = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'EncryptedID')[0];

        if (typeof encSubject !== 'undefined') {
            const options = { key: this.serviceProviderOptions.privateKey };
            xmlenc.decrypt(encSubject.getElementsByTagNameNS('*', 'EncryptedData')[0], options, (err, result) => {
                if (err) {
                    SAMLUtils.error(err);
                }
                subject = new xmldom.DOMParser().parseFromString(result, 'text/xml');
            });
        }

        return subject;
    }

    private validateSubjectConditions(subject: XmlParent): void {
        const subjectConfirmation = subject.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'SubjectConfirmation')[0];
        if (subjectConfirmation) {
            const subjectConfirmationData = subjectConfirmation.getElementsByTagNameNS(
                'urn:oasis:names:tc:SAML:2.0:assertion',
                'SubjectConfirmationData',
            )[0];
            if (subjectConfirmationData && !this.validateNotBeforeNotOnOrAfterAssertions(subjectConfirmationData)) {
                throw new Error('NotBefore / NotOnOrAfter assertion failed');
            }
        }
    }

    private validateNotBeforeNotOnOrAfterAssertions(element: Element): boolean {
        const sysnow = new Date();
        const allowedclockdrift = this.serviceProviderOptions.allowedClockDrift || 0;

        const now = new Date(sysnow.getTime() + allowedclockdrift);

        if (element.hasAttribute('NotBefore')) {
            const notBefore: string | null = element.getAttribute('NotBefore');

            if (!notBefore) {
                return false;
            }

            const date = new Date(notBefore);
            if (now < date) {
                return false;
            }
        }

        if (element.hasAttribute('NotOnOrAfter')) {
            const notOnOrAfter: string | null = element.getAttribute('NotOnOrAfter');
            if (!notOnOrAfter) {
                return false;
            }

            const date = new Date(notOnOrAfter);

            if (now >= date) {
                return false;
            }
        }

        return true;
    }

    private validateAssertionConditions(assertion: XmlParent): void {
        const conditions = assertion.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Conditions')[0];
        if (conditions && !this.validateNotBeforeNotOnOrAfterAssertions(conditions)) {
            throw new Error('NotBefore / NotOnOrAfter assertion failed');
        }
    }

    private mapAttributes(attributeStatement: Element, profile: Record<string, any>): void {
        SAMLUtils.log(`Attribute Statement found in SAML response: ${attributeStatement}`);
        const attributes = attributeStatement.getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'Attribute');
        SAMLUtils.log(`Attributes will be processed: ${attributes.length}`);

        if (attributes) {
            for (let i = 0; i < attributes.length; i++) {
                const values = attributes[i].getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:assertion', 'AttributeValue');
                let value;
                if (values.length === 1) {
                    value = values[0].textContent;
                } else {
                    value = [];
                    for (let j = 0; j < values.length; j++) {
                        value.push(values[j].textContent);
                    }
                }

                const key = attributes[i].getAttribute('Name');
                if (key) {
                    SAMLUtils.log(`Attribute:  ${attributes[i]} has ${values.length} value(s).`);
                    SAMLUtils.log(`Adding attribute from SAML response to profile: ${key} = ${value}`);
                    profile[key] = value;
                }
            }
        } else {
            SAMLUtils.log('No Attributes found in SAML attribute statement.');
        }

        if (!profile.mail && profile['urn:oid:0.9.2342.19200300.100.1.3']) {
            // See http://www.incommonfederation.org/attributesummary.html for definition of attribute OIDs
            profile.mail = profile['urn:oid:0.9.2342.19200300.100.1.3'];
        }

        if (!profile.email && profile['urn:oid:1.2.840.113549.1.9.1']) {
            profile.email = profile['urn:oid:1.2.840.113549.1.9.1'];
        }

        if (!profile.displayName && profile['urn:oid:2.16.840.1.113730.3.1.241']) {
            profile.displayName = profile['urn:oid:2.16.840.1.113730.3.1.241'];
        }

        if (!profile.eppn && profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) {
            profile.eppn = profile['urn:oid:1.3.6.1.4.1.5923.1.1.1.6'];
        }

        if (!profile.email && profile.mail) {
            profile.email = profile.mail;
        }

        if (!profile.cn && profile['urn:oid:2.5.4.3']) {
            profile.cn = profile['urn:oid:2.5.4.3'];
        }
    }
}