RocketChat/Rocket.Chat

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

Summary

Maintainability
C
1 day
Test Coverage
import crypto from 'crypto';
import querystring from 'querystring';
import util from 'util';
import zlib from 'zlib';

import { Meteor } from 'meteor/meteor';

import type { ILogoutResponse } from '../definition/ILogoutResponse';
import type { ISAMLRequest } from '../definition/ISAMLRequest';
import type { IServiceProviderOptions } from '../definition/IServiceProviderOptions';
import type { ILogoutRequestValidateCallback, ILogoutResponseValidateCallback, IResponseValidateCallback } from '../definition/callbacks';
import { SAMLUtils } from './Utils';
import { AuthorizeRequest } from './generators/AuthorizeRequest';
import { LogoutRequest } from './generators/LogoutRequest';
import { LogoutResponse } from './generators/LogoutResponse';
import { ServiceProviderMetadata } from './generators/ServiceProviderMetadata';
import { LogoutRequestParser } from './parsers/LogoutRequest';
import { LogoutResponseParser } from './parsers/LogoutResponse';
import { ResponseParser } from './parsers/Response';

export class SAMLServiceProvider {
    serviceProviderOptions: IServiceProviderOptions;

    constructor(serviceProviderOptions: IServiceProviderOptions) {
        if (!serviceProviderOptions) {
            throw new Error('SAMLServiceProvider instantiated without an options object');
        }

        this.serviceProviderOptions = serviceProviderOptions;
    }

    private signRequest(xml: string): string {
        const signer = crypto.createSign('RSA-SHA1');
        signer.update(xml);
        return signer.sign(this.serviceProviderOptions.privateKey, 'base64');
    }

    public generateAuthorizeRequest(): string {
        const identifiedRequest = AuthorizeRequest.generate(this.serviceProviderOptions);
        return identifiedRequest.request;
    }

    public generateLogoutResponse({
        nameID,
        sessionIndex,
        inResponseToId,
    }: {
        nameID: string;
        sessionIndex: string;
        inResponseToId: string;
    }): ILogoutResponse {
        return LogoutResponse.generate(this.serviceProviderOptions, nameID, sessionIndex, inResponseToId);
    }

    public generateLogoutRequest({ nameID, sessionIndex }: { nameID: string; sessionIndex: string }): ISAMLRequest {
        return LogoutRequest.generate(this.serviceProviderOptions, nameID, sessionIndex);
    }

    /*
        This method will generate the response URL with all the query string params and pass it to the callback
    */
    public logoutResponseToUrl(response: string, callback: (err: string | object | null, url?: string) => void): void {
        zlib.deflateRaw(response, (err, buffer) => {
            if (err) {
                return callback(err);
            }

            try {
                const base64 = buffer.toString('base64');
                let target = this.serviceProviderOptions.idpSLORedirectURL;

                if (target.indexOf('?') > 0) {
                    target += '&';
                } else {
                    target += '?';
                }

                // TBD. We should really include a proper RelayState here
                const relayState = Meteor.absoluteUrl();

                const samlResponse: Record<string, any> = {
                    SAMLResponse: base64,
                    RelayState: relayState,
                };

                if (this.serviceProviderOptions.privateCert) {
                    samlResponse.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
                    samlResponse.Signature = this.signRequest(querystring.stringify(samlResponse));
                }

                target += querystring.stringify(samlResponse);

                return callback(null, target);
            } catch (error) {
                return callback(error instanceof Error ? error : String(error));
            }
        });
    }

    /*
        This method will generate the request URL with all the query string params and pass it to the callback
    */
    public async requestToUrl(request: string, operation: string): Promise<string | undefined> {
        const buffer = await util.promisify(zlib.deflateRaw)(request);
        try {
            const base64 = buffer.toString('base64');
            let target = this.serviceProviderOptions.entryPoint;

            if (operation === 'logout') {
                if (this.serviceProviderOptions.idpSLORedirectURL) {
                    target = this.serviceProviderOptions.idpSLORedirectURL;
                }
            }

            if (target.indexOf('?') > 0) {
                target += '&';
            } else {
                target += '?';
            }

            // TBD. We should really include a proper RelayState here
            let relayState;
            if (operation === 'logout') {
                // in case of logout we want to be redirected back to the Meteor app.
                relayState = Meteor.absoluteUrl();
            } else {
                relayState = this.serviceProviderOptions.provider;
            }

            const samlRequest: Record<string, any> = {
                SAMLRequest: base64,
                RelayState: relayState,
            };

            if (this.serviceProviderOptions.privateCert) {
                samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
                samlRequest.Signature = this.signRequest(querystring.stringify(samlRequest));
            }

            target += querystring.stringify(samlRequest);

            SAMLUtils.log(`requestToUrl: ${target}`);

            if (operation === 'logout') {
                // in case of logout we want to be redirected back to the Meteor app.
                return target;
            }
            return target;
        } catch (error) {
            throw error instanceof Error ? error : String(error);
        }
    }

    public async getAuthorizeUrl(): Promise<string | undefined> {
        const request = this.generateAuthorizeRequest();
        SAMLUtils.log('-----REQUEST------');
        SAMLUtils.log(request);

        return this.requestToUrl(request, 'authorize');
    }

    public async validateLogoutRequest(samlRequest: string, callback: ILogoutRequestValidateCallback): Promise<void> {
        await SAMLUtils.inflateXml(
            samlRequest,
            async (xml: string) => {
                const parser = new LogoutRequestParser(this.serviceProviderOptions);
                return parser.validate(xml, callback);
            },
            async (err: string | object | null) => {
                await callback(err, null);
            },
        );
    }

    public async validateLogoutResponse(samlResponse: string, callback: ILogoutResponseValidateCallback): Promise<void> {
        await SAMLUtils.inflateXml(
            samlResponse,
            async (xml: string) => {
                const parser = new LogoutResponseParser(this.serviceProviderOptions);
                return parser.validate(xml, callback);
            },
            async (err: string | object | null) => {
                await callback(err, null);
            },
        );
    }

    public validateResponse(samlResponse: string, callback: IResponseValidateCallback): void {
        const xml = Buffer.from(samlResponse, 'base64').toString('utf8');

        const parser = new ResponseParser(this.serviceProviderOptions);
        return parser.validate(xml, callback);
    }

    public generateServiceProviderMetadata(): string {
        return ServiceProviderMetadata.generate(this.serviceProviderOptions);
    }
}