import zlib from 'node:zlib'; import { isTruthy } from '@rocket.chat/tools'; import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; import { serviceProviderOptions, simpleMetadata, metadataWithCertificate, simpleLogoutRequest, invalidXml, randomXml, invalidLogoutRequest, simpleLogoutResponse, invalidLogoutResponse, simpleSamlResponse, samlResponse, duplicatedSamlResponse, samlResponseMissingStatus, samlResponseFailedStatus, samlResponseMultipleAssertions, samlResponseMissingAssertion, samlResponseMultipleIssuers, samlResponseWithConditions, samlResponseAssertionId, samlResponseValidSignatures, samlResponseValidAssertionSignature, samlResponseNestedInWrapper, samlResponseSignatureReferenceMismatch, encryptedResponse, profile, certificate, privateKeyCert, privateKey, } from './data'; import { makeLoginResponseEnvelope, makeLogoutRequestEnvelope, makeLogoutResponseEnvelope } from './helpers'; import { SAMLUtils } from '../../../../../server/lib/saml/lib/Utils'; import { AuthorizeRequest } from '../../../../../server/lib/saml/lib/generators/AuthorizeRequest'; import { LogoutRequest } from '../../../../../server/lib/saml/lib/generators/LogoutRequest'; import { LogoutResponse } from '../../../../../server/lib/saml/lib/generators/LogoutResponse'; import { LogoutRequestParser } from '../../../../../server/lib/saml/lib/parsers/LogoutRequest'; import { LogoutResponseParser } from '../../../../../server/lib/saml/lib/parsers/LogoutResponse'; import { ResponseParser } from '../../../../../server/lib/saml/lib/parsers/Response'; const meteorStub = { 'meteor/meteor': { '@global': true, 'Meteor': { absoluteUrl() { return 'http://localhost:3000/'; }, }, }, }; const { ServiceProviderMetadata } = proxyquire .noCallThru() .load('../../../../../server/lib/saml/lib/generators/ServiceProviderMetadata', meteorStub); const { SAMLServiceProvider } = proxyquire.noCallThru().load('../../../../../server/lib/saml/lib/ServiceProvider', meteorStub); describe('SAML', () => { describe('[AuthorizeRequest]', () => { describe('AuthorizeRequest.generate', () => { it('should use the custom templates to generate the request', () => { const credentialToken = '__credentialToken__'; const authorizeRequest = AuthorizeRequest.generate(serviceProviderOptions, credentialToken); expect(authorizeRequest.id).to.be.equal(credentialToken); expect(authorizeRequest.request).to.be.equal( ' Password ', ); }); it('should include the unique ID on the request', () => { const credentialToken = '__credentialToken__'; const customOptions = { ...serviceProviderOptions, authRequestTemplate: '__newId__', }; const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); expect(authorizeRequest.id).to.be.equal(credentialToken); expect(authorizeRequest.request).to.be.equal(authorizeRequest.id); }); it('should include the custom options on the request', () => { const credentialToken = '__credentialToken__'; const customOptions = { ...serviceProviderOptions, authRequestTemplate: '__callbackUrl__ __entryPoint__ __issuer__', }; const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); expect(authorizeRequest.id).to.be.equal(credentialToken); expect(authorizeRequest.request).to.be.equal('[callback-url] [entry-point] [issuer]'); }); it('should omit the authn context block when the customAuthnContext setting is empty', () => { const credentialToken = '__credentialToken__'; const customOptions = { ...serviceProviderOptions, customAuthnContext: '', }; const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); expect(authorizeRequest.request).to.not.include(' { const credentialToken = '__credentialToken__'; const customOptions = { ...serviceProviderOptions, customAuthnContext: ' ', }; const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken); expect(authorizeRequest.request).to.not.include(' { describe('LogoutRequest.generate', () => { it('should use the custom template to generate the request', () => { const logoutRequest = LogoutRequest.generate(serviceProviderOptions, 'NameID', 'sessionIndex'); expect(logoutRequest.request).to.be.equal('[logout-request-template]'); }); it('should include the unique ID on the request', () => { const customOptions = { ...serviceProviderOptions, logoutRequestTemplate: '__newId__', }; const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); expect(logoutRequest.request).to.be.equal(logoutRequest.id); }); it('should include the custom options on the request', () => { const customOptions = { ...serviceProviderOptions, logoutRequestTemplate: '__idpSLORedirectURL__ __issuer__ __identifierFormat__ __nameID__ __sessionIndex__', }; const logoutRequest = LogoutRequest.generate(customOptions, 'NameID', 'sessionIndex'); expect(logoutRequest.request).to.be.equal('[idpSLORedirectURL] [issuer] email NameID sessionIndex'); }); }); describe('LogoutRequest.validate', () => { it('should extract the idpSession and nameID from the request', () => { const parser = new LogoutRequestParser(serviceProviderOptions); void parser.validate(makeLogoutRequestEnvelope(simpleLogoutRequest), async (err, data) => { expect(err).to.be.null; expect(data).to.be.an('object'); expect(data).to.have.property('idpSession'); expect(data).to.have.property('nameID'); // @ts-ignore -- chai already ensured the object exists expect(data.idpSession).to.be.equal('_d6ad0e25459aaddd0433a81e159aa79e55dc52c280'); // @ts-ignore -- chai already ensured the object exists expect(data.nameID).to.be.equal('_ab7e1d9a603473e92148d569d50176bafa60bcb2e9'); }); }); it('should fail to parse an invalid xml', () => { const parser = new LogoutRequestParser(serviceProviderOptions); void parser.validate(makeLogoutRequestEnvelope(invalidXml), async (err, data) => { expect(err).to.exist; expect(data).to.not.exist; }); }); it('should fail to parse a xml without any LogoutRequest tag', () => { const parser = new LogoutRequestParser(serviceProviderOptions); void parser.validate(makeLogoutRequestEnvelope(randomXml), async (err, data) => { expect(err).to.be.equal('No Request Found'); expect(data).to.not.exist; }); }); it('should fail to parse a request with no NameId', () => { const parser = new LogoutRequestParser(serviceProviderOptions); void parser.validate(makeLogoutRequestEnvelope(invalidLogoutRequest), async (err, data) => { expect(err).to.be.an('error').that.has.property('message').equal('SAML Logout Request: No NameID node found'); expect(data).to.not.exist; }); }); }); }); describe('[LogoutResponse]', () => { describe('LogoutResponse.generate', () => { it('should use the custom template to generate the response', () => { const logoutResponse = LogoutResponse.generate(serviceProviderOptions, 'NameID', 'sessionIndex', 'inResponseToId'); expect(logoutResponse.response).to.be.equal('[logout-response-template]'); }); it('should include the unique ID on the response', () => { const customOptions = { ...serviceProviderOptions, logoutResponseTemplate: '__newId__', }; const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); expect(logoutResponse.response).to.be.equal(logoutResponse.id); }); it('should include the custom options on the response', () => { const customOptions = { ...serviceProviderOptions, logoutResponseTemplate: '__idpSLORedirectURL__ __issuer__', }; const logoutResponse = LogoutResponse.generate(customOptions, 'NameID', 'sessionIndex', 'inResponseToId'); expect(logoutResponse.response).to.be.equal('[idpSLORedirectURL] [issuer]'); }); }); describe('LogoutResponse.validate', () => { it('should extract the inResponseTo from the response', () => { const logoutResponse = simpleLogoutResponse.replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success'); const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { expect(err).to.be.null; expect(inResponseTo).to.be.equal('_id-6530db3fcd23dc42a31c'); }); }); it('should reject a response with a non-success StatusCode', () => { const logoutResponse = simpleLogoutResponse.replace('[STATUSCODE]', 'Anything'); const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { expect(err).to.be.equal('Error. Logout not confirmed by IDP'); expect(inResponseTo).to.be.null; }); }); it('should fail to parse an invalid xml', () => { const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(invalidXml), async (err, inResponseTo) => { expect(err).to.exist; expect(inResponseTo).to.not.exist; }); }); it('should fail to parse a xml without any LogoutResponse tag', () => { const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(randomXml), async (err, inResponseTo) => { expect(err).to.be.equal('No Response Found'); expect(inResponseTo).to.not.exist; }); }); it('should fail to parse a xml without an inResponseTo attribute', () => { const instant = new Date().toISOString(); const logoutResponse = simpleLogoutResponse .replace('[INSTANT]', instant) .replace('[STATUSCODE]', 'urn:oasis:names:tc:SAML:2.0:status:Success') .replace('InResponseTo=', 'SomethingElse='); const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(logoutResponse), async (err, inResponseTo) => { expect(err).to.be.equal('Unexpected Response from IDP'); expect(inResponseTo).to.not.exist; }); }); it('should reject a response with no status tag', () => { const parser = new LogoutResponseParser(serviceProviderOptions); void parser.validate(makeLogoutResponseEnvelope(invalidLogoutResponse), async (err, inResponseTo) => { expect(err).to.be.equal('Error. Logout not confirmed by IDP'); expect(inResponseTo).to.be.null; }); }); }); describe('logoutResponseToUrl', () => { const serviceProvider = new SAMLServiceProvider(serviceProviderOptions); const generateUrl = (relayState: string | undefined): Promise => new Promise((resolve, reject) => { serviceProvider.logoutResponseToUrl('logout-response', relayState, (err: unknown, url?: string) => { if (err || !url) { return reject(err ?? new Error('No URL returned')); } resolve(url); }); }); it('should echo back the exact RelayState received on the request', async () => { const relayState = 'idp-shared-session-relay-state'; const url = await generateUrl(relayState); const params = new URLSearchParams(url.split('?')[1]); expect(url).to.match(/^\[idpSLORedirectURL\]\?/); expect(params.get('SAMLResponse')).to.be.a('string').that.is.not.empty; expect(params.get('RelayState')).to.be.equal(relayState); }); it('should preserve a RelayState that requires URL encoding', async () => { const relayState = 'https://sp.example.com/return?foo=bar baz&x=1'; const url = await generateUrl(relayState); const params = new URLSearchParams(url.split('?')[1]); expect(params.get('RelayState')).to.be.equal(relayState); }); it('should omit RelayState entirely when none was received on the request', async () => { const url = await generateUrl(undefined); const params = new URLSearchParams(url.split('?')[1]); expect(params.has('RelayState')).to.be.false; expect(params.get('SAMLResponse')).to.be.a('string').that.is.not.empty; }); it('should target the IdP SLO endpoint with the deflated response and the relay state', async () => { const url = await generateUrl('relay-123'); const [target, query] = url.split('?'); const params = new URLSearchParams(query); expect(target).to.be.equal('[idpSLORedirectURL]'); expect([...params.keys()]).to.be.deep.equal(['SAMLResponse', 'RelayState']); expect(params.get('RelayState')).to.be.equal('relay-123'); const inflated = zlib.inflateRawSync(Buffer.from(params.get('SAMLResponse') ?? '', 'base64')).toString(); expect(inflated).to.be.equal('logout-response'); }); }); }); describe('[Metadata]', () => { describe('[Metadata.generate]', () => { it('should generate a simple metadata file when no certificate info is included', () => { const metadata = ServiceProviderMetadata.generate(serviceProviderOptions); expect(metadata).to.be.equal(simpleMetadata); }); it('should include additional information when a certificate is provided', () => { const customOptions = { ...serviceProviderOptions, privateCert: '[CERTIFICATE_CONTENT]', privateKey: '[PRIVATE_KEY]', }; const metadata = ServiceProviderMetadata.generate(customOptions); expect(metadata).to.be.equal(metadataWithCertificate); }); }); }); describe('[Response]', () => { describe('[Response.validate]', () => { it('should extract a profile from the response', () => { const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = simpleSamlResponse .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { expect(err).to.be.null; expect(profile).to.be.an('object'); expect(profile).to.have.property('inResponseToId').equal('[INRESPONSETO]'); expect(profile).to.have.property('issuer').equal('[ISSUER]'); expect(profile).to.have.property('nameID').equal('[NAMEID]'); expect(profile).to.have.property('sessionIndex').equal('[SESSIONINDEX]'); expect(profile).to.have.property('uid').equal('1'); expect(profile).to.have.property('eduPersonAffiliation').equal('group1'); expect(profile).to.have.property('email').equal('user1@example.com'); expect(profile).to.have.property('channels').that.is.an('array').that.is.deep.equal(['channel1', 'pets', 'random']); expect(loggedOut).to.be.false; }); }); it('should contain properties to avoid assertions replay in the profile object from the response', () => { const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = samlResponseWithConditions .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { expect(err).to.be.null; expect(profile).to.be.an('object'); expect(profile).to.have.property('assertionId').equal(samlResponseAssertionId); expect(profile).to.have.property('expireAt'); // `expireAt` mirrors the assertion's NotOnOrAfter condition. expect(new Date((profile as any).expireAt).toISOString()).to.equal(notOnOrAfter.toISOString()); expect(loggedOut).to.be.false; }); }); it('should respect NotOnOrAfter conditions', () => { const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', new Date().toISOString()); const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(profile).to.be.null; expect(loggedOut).to.be.false; }); }); it('should respect NotBefore conditions', () => { const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() + 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(profile).to.be.null; expect(loggedOut).to.be.false; }); }); it('should fail to parse an invalid xml', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(invalidXml), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should fail to parse a xml without any Response tag', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(randomXml), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unknown SAML response message'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject a xml with multiple responses', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(duplicatedSamlResponse), (err, data, loggedOut) => { expect(err).to.be.an('error'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should fail to parse a reponse with no Status tag', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(samlResponseMissingStatus), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing StatusCode'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should fail to parse a reponse with a failed status', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(samlResponseFailedStatus), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Status is: Failed'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject a response with multiple assertions', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(samlResponseMultipleAssertions), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many SAML assertions'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject a response with no assertions', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(samlResponseMissingAssertion), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Missing SAML assertion'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject a document without signatures if the setting requires at least one', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Either', cert: 'invalidCert', }; const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = simpleSamlResponse .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('No valid SAML Signature found'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject a document with multiple issuers', () => { const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(samlResponseMultipleIssuers), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Too many Issuers'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should decrypt an encrypted response', () => { const options = { ...serviceProviderOptions, privateCert: privateKeyCert, privateKey, }; const parser = new ResponseParser(options); parser.validate(makeLoginResponseEnvelope(encryptedResponse), (err, profile, loggedOut) => { // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should validate signatures on an encrypted response', () => { const options = { ...serviceProviderOptions, privateCert: privateKeyCert, signatureValidationType: 'All', privateKey, cert: certificate, }; const parser = new ResponseParser(options); parser.validate(makeLoginResponseEnvelope(encryptedResponse), (err, profile, loggedOut) => { // No way to change the assertion conditions on an encrypted response ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); }); describe('[Validate Signatures]', () => { it('should reject an unsigned assertion if the setting says so', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Assertion', cert: 'invalidCert', }; const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = simpleSamlResponse .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject an unsigned response if the setting says so', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Response', cert: 'invalidCert', }; const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = simpleSamlResponse .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should reject an assertion signed with an invalid signature', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Assertion', cert: certificate, }; const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = samlResponse.replace('[NOTBEFORE]', notBefore.toISOString()).replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(response), (err, data, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Assertion signature'); expect(data).to.not.exist; expect(loggedOut).to.be.false; }); }); it('should accept an assertion with a valid signature', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Assertion', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should reject an assertion with a valid signature if the certificate is not configured', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Assertion', }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('Unable to validate signature'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should accept a document with a valid response signature', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Response', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidSignatures), (err, profile, loggedOut) => { // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should reject a document with a valid signature of the wrong type', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Response', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should accept a document with both valid signatures', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'All', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidSignatures), (err, profile, loggedOut) => { // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should reject a document with a single signature when both are expected', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'All', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); it('should accept a document with either valid signature', () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType: 'Either', cert: certificate, }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseValidAssertionSignature), (err, profile, loggedOut) => { // To have a valid signature, we can't change the assertion conditions ¯\_(ツ)_/¯ expect(err).to.be.an('error').that.has.property('message').that.is.equal('NotBefore / NotOnOrAfter assertion failed'); expect(loggedOut).to.be.false; expect(profile).to.be.null; }); }); for (const signatureValidationType of ['None', 'Response', 'Assertion', 'Either', 'All']) { it(`should reject a response that is not the document root (${signatureValidationType})`, () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType, cert: certificate }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseNestedInWrapper), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('SAML Response must be the document root'); expect(profile).to.not.exist; expect(loggedOut).to.be.false; }); }); } for (const signatureValidationType of ['Response', 'All']) { it(`should reject a response whose signature does not cover the validated element (${signatureValidationType})`, () => { const providerOptions = { ...serviceProviderOptions, signatureValidationType, cert: certificate }; const parser = new ResponseParser(providerOptions); parser.validate(makeLoginResponseEnvelope(samlResponseSignatureReferenceMismatch), (err, profile, loggedOut) => { expect(err).to.be.an('error').that.has.property('message').that.is.equal('Invalid Signature'); expect(profile).to.not.exist; expect(loggedOut).to.be.false; }); }); } }); }); describe('[Login]', () => { describe('UserMapping', () => { it('should collect all appropriate data from the profile, respecting the fieldMap', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: 'anotherUsername', email: 'singleEmail', name: 'anotherName', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); SAMLUtils.relayState = '[RelayState]'; const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('samlLogin').that.is.an('object'); expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[IssuerName]'); expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SessionIndex]'); expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[nameID]'); expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('testing@server.com'); expect(userObject).to.have.property('fullName').that.is.equal('[AnotherName]'); expect(userObject).to.have.property('username').that.is.equal('[AnotherUserName]'); expect(userObject).to.not.have.property('roles'); expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny', 'random', 'babies']); }); it('should join array values if username receives an array of values', () => { const { globalSettings } = SAMLUtils; const multipleUsernames = { ...profile, anotherUsername: ['user1', 'user2'], }; SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(multipleUsernames); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('samlLogin').that.is.an('object'); expect(userObject).to.have.property('username').that.is.equal('user1 user2'); }); // Channels support both a comma separated single value and an array of values it('should support `channels` attribute with multiple values', () => { const channelsProfile = { ...profile, channels: ['pets', 'pics', 'funny'], }; const userObject = SAMLUtils.mapProfileToUserObject(channelsProfile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('channels').that.is.an('array').with.members(['pets', 'pics', 'funny']); }); it('should reject an userDataFieldMap without an email field', () => { const { globalSettings } = SAMLUtils; globalSettings.userDataFieldMap = JSON.stringify({}); SAMLUtils.updateGlobalSettings(globalSettings); expect(() => { SAMLUtils.mapProfileToUserObject(profile); }).to.throw('SAML Profile did not contain an email address'); }); it('should fail to map a profile that is missing the email field', () => { const { globalSettings } = SAMLUtils; const fieldMap = { inexistentField: 'email', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); expect(() => { SAMLUtils.mapProfileToUserObject(profile); }).to.throw('SAML Profile did not contain an email address'); }); it('should load data from the default fields when the field map is lacking', () => { const { globalSettings } = SAMLUtils; const fieldMap = { email: 'singleEmail', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName]'); expect(userObject).to.have.property('username').that.is.equal('[username]'); }); it('should run custom regexes when one is used', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: { fieldName: 'singleEmail', regex: '(.*)@.+$', }, email: 'singleEmail', name: 'anotherName', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); SAMLUtils.relayState = '[RelayState]'; const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('username').that.is.equal('testing'); }); it('should run custom templates when one is used', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: { fieldName: 'anotherName', template: 'user-__anotherName__', }, email: 'singleEmail', name: { fieldNames: ['anotherName', 'displayName'], template: '__displayName__ (__anotherName__)', }, }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); SAMLUtils.relayState = '[RelayState]'; const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('username').that.is.equal('user-[AnotherName]'); expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] ([AnotherName])'); }); it('should combine regexes and templates when both are used', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: { fieldName: 'anotherName', template: 'user-__anotherName__45@7', regex: 'user-(.*)@', }, email: 'singleEmail', name: { fieldNames: ['anotherName', 'displayName'], regex: '\\[(.*)\\]', template: '__displayName__ (__regex__)', }, }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); SAMLUtils.relayState = '[RelayState]'; const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); // should run the template first, then the regex expect(userObject).to.have.property('username').that.is.equal('[AnotherName]45'); // for this one, should run the regex first, then the template expect(userObject).to.have.property('fullName').that.is.equal('[DisplayName] (AnotherName)'); }); it('should support individual array values on templates', () => { const { globalSettings } = SAMLUtils; const multipleUsernames = { ...profile, anotherUsername: ['1', '2'], }; const fieldMap = { username: { fieldName: 'anotherUsername', template: 'user-__anotherUsername[-1]__', }, email: { fieldName: 'anotherUsername', template: 'user-__anotherUsername[0]__', }, }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(multipleUsernames); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('username').that.is.equal('user-2'); expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('user-1'); }); it('should collect the values of every attribute on the field map', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: 'anotherUsername', email: 'singleEmail', name: 'anotherName', others: { fieldNames: ['issuer', 'sessionIndex', 'nameID', 'displayName', 'username', 'roles', 'otherRoles', 'language', 'channels'], }, }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject) .to.have.property('attributeList') .that.is.a('Map') .that.have.keys([ 'anotherUsername', 'singleEmail', 'anotherName', 'issuer', 'sessionIndex', 'nameID', 'displayName', 'username', 'roles', 'otherRoles', 'language', 'channels', ]); // Workaround because chai doesn't handle Maps very well for (const [key, value] of userObject.attributeList) { expect(value).to.be.equal(profile[key as keyof typeof profile]); } }); it('should use the immutable property as default identifier', () => { const { globalSettings } = SAMLUtils; globalSettings.immutableProperty = 'EMail'; SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('email'); globalSettings.immutableProperty = 'Username'; SAMLUtils.updateGlobalSettings(globalSettings); const newUserObject = SAMLUtils.mapProfileToUserObject(profile); expect(newUserObject).to.be.an('object'); expect(newUserObject).to.have.property('identifier').that.has.property('type').that.is.equal('username'); }); it('should collect the identifier from the fieldset', () => { const { globalSettings } = SAMLUtils; const fieldMap = { username: 'anotherUsername', email: 'singleEmail', name: 'anotherName', __identifier__: 'customField3', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('identifier').that.has.property('type').that.is.equal('custom'); expect(userObject).to.have.property('identifier').that.has.property('attribute').that.is.equal('customField3'); }); }); }); describe('Response Mapping', () => { it('should extract a mapped user from the response', () => { const notBefore = new Date(); notBefore.setMinutes(notBefore.getMinutes() - 3); const notOnOrAfter = new Date(); notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 3); const response = simpleSamlResponse .replace('[NOTBEFORE]', notBefore.toISOString()) .replace('[NOTONORAFTER]', notOnOrAfter.toISOString()); const parser = new ResponseParser(serviceProviderOptions); parser.validate(makeLoginResponseEnvelope(response), (err, profile, loggedOut) => { expect(profile).to.be.an('object'); expect(err).to.be.null; expect(loggedOut).to.be.false; const { globalSettings } = SAMLUtils; const fieldMap = { username: { fieldName: 'uid', template: 'user-__uid__', }, email: 'email', }; globalSettings.userDataFieldMap = JSON.stringify(fieldMap); SAMLUtils.updateGlobalSettings(globalSettings); SAMLUtils.relayState = '[RelayState]'; if (!isTruthy(profile)) { throw new Error('Profile is null'); } const userObject = SAMLUtils.mapProfileToUserObject(profile); expect(userObject).to.be.an('object'); expect(userObject).to.have.property('samlLogin').that.is.an('object'); expect(userObject).to.have.nested.property('samlLogin.provider').that.is.equal('[RelayState]'); expect(userObject).to.have.nested.property('samlLogin.idp').that.is.equal('[ISSUER]'); expect(userObject).to.have.nested.property('samlLogin.idpSession').that.is.equal('[SESSIONINDEX]'); expect(userObject).to.have.nested.property('samlLogin.nameID').that.is.equal('[NAMEID]'); expect(userObject).to.have.property('emailList').that.is.an('array').that.includes('user1@example.com'); expect(userObject).to.have.property('username').that.is.equal('user-1'); const map = new Map(); map.set('epa', 'group1'); }); }); }); describe('[Utils]', () => { it('should return correct validation action redirect path', () => { const credentialToken = SAMLUtils.generateUniqueID(); expect(SAMLUtils.getValidationActionRedirectPath(credentialToken)).to.be.equal( `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`, ); }); describe('authorize RelayState encoding', () => { it('should keep the provider when no loginClient is provided', () => { expect(SAMLUtils.encodeAuthorizeRelayState('test-sp')).to.be.equal('test-sp'); }); it('should ignore unsupported loginClient values and keep the provider', () => { expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', 'web')).to.be.equal('test-sp'); expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', '')).to.be.equal('test-sp'); }); it('should round-trip provider and loginClient for supported clients', () => { for (const loginClient of ['mobile', 'desktop']) { const encoded = SAMLUtils.encodeAuthorizeRelayState('test-sp', loginClient); expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'test-sp', loginClient }); } }); it('should drop an unexpected loginClient echoed back in a compound RelayState', () => { expect(SAMLUtils.decodeAuthorizeRelayState('provider=test-sp&loginClient=hacker')).to.be.deep.equal({ provider: 'test-sp', loginClient: undefined, }); }); it('should preserve providers containing URL-special characters', () => { const encoded = SAMLUtils.encodeAuthorizeRelayState('a b&c=d', 'mobile'); expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'a b&c=d', loginClient: 'mobile' }); }); }); }); describe('[SAML.processRequest] validate action - assertion replay protection', () => { const markUsed = sinon.stub(); const credentialCreate = sinon.stub().resolves(); // `pending` captures the promise returned by the (async) validateResponse callback, // because processValidateAction does not await it. Awaiting it in the test guarantees // markUsed / storeCredential / redirect have already run before we assert. let pending: Promise | undefined; let lastRedirect: string | undefined; // A profile as produced by ResponseParser, carrying the replay-guard fields. const replayProfile = { assertionId: samlResponseAssertionId, issuer: '[ISSUER]', expireAt: new Date(Date.now() + 5 * 60 * 1000), }; const res = { writeHead: (_code: number, headers?: Record) => { lastRedirect = headers?.Location; }, write: () => undefined, end: () => undefined, }; const loadSAML = () => proxyquire.noCallThru().load('../../../../../server/lib/saml/lib/SAML', { '@rocket.chat/models': { SamlUsedAssertions: { markUsed }, CredentialTokens: { create: credentialCreate }, Users: {}, Rooms: {}, Roles: {}, }, '@rocket.chat/random': { Random: { id: () => '__credentialToken__' } }, '@rocket.chat/tools': { escapeRegExp: (s: string) => s, escapeHTML: (s: string) => s }, 'meteor/accounts-base': { Accounts: { _generateStampedLoginToken: () => ({ token: 't' }) } }, 'meteor/meteor': { Meteor: { absoluteUrl: (path = '') => `http://localhost:3000/${path}`, Error } }, './ServiceProvider': { SAMLServiceProvider: class { validateResponse(_envelope: unknown, cb: (err: Error | null, profile: any) => Promise) { pending = cb(null, replayProfile); } }, }, './Utils': { SAMLUtils: { relayState: null, error: sinon.stub(), warn: sinon.stub(), log: sinon.stub(), getValidationActionRedirectPath: (token: string) => `_saml/validate/${token}`, decodeAuthorizeRelayState: (provider: string) => ({ provider }), }, }, './getSAMLEnvelope': { getSAMLEnvelope: async () => ({ relayState: null }) }, '../../../../lib/utils/arrayUtils': { ensureArray: (v: any) => v }, '../../logger/system': { SystemLogger: { error: sinon.stub(), warn: sinon.stub() } }, '../../rooms/addUserToRoom': { addUserToRoom: sinon.stub() }, '../../rooms/createRoom': { createRoom: sinon.stub() }, '../../users/getUsernameSuggestion': { generateUsernameSuggestion: sinon.stub() }, '../../users/saveUserIdentity': { saveUserIdentity: sinon.stub() }, '../../../settings': { settings: { get: sinon.stub() } }, '../../../../app/utils/lib/i18n': { i18n: { t: (s: string) => s, languages: [] } }, }).SAML; const service = { ...serviceProviderOptions } as any; const samlObject = { actionName: 'validate', serviceName: 'test-sp', credentialToken: '' } as any; beforeEach(() => { markUsed.reset(); credentialCreate.reset(); credentialCreate.resolves(); pending = undefined; lastRedirect = undefined; }); it('should mark the assertion as used and store the credential for a fresh assertion', async () => { markUsed.resolves(true); const SAML = loadSAML(); await SAML.processRequest({} as any, res as any, service, samlObject); await pending; expect(markUsed.calledOnceWithExactly(replayProfile.assertionId, replayProfile.issuer, replayProfile.expireAt)).to.be.true; expect(credentialCreate.calledOnce).to.be.true; expect(lastRedirect).to.equal('http://localhost:3000/_saml/validate/__credentialToken__'); }); it('should reject a replayed assertion: no credential stored and redirect to the base url', async () => { markUsed.resolves(false); const SAML = loadSAML(); await SAML.processRequest({} as any, res as any, service, samlObject); await pending; expect(markUsed.calledOnce).to.be.true; expect(credentialCreate.called).to.be.false; expect(lastRedirect).to.equal('http://localhost:3000/'); }); }); });