Files
upstream-rocketchat/apps/meteor/tests/unit/server/lib/saml/server.tests.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

1268 lines
48 KiB
TypeScript

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(
'<authRequest><NameID IdentifierFormat="email"/> <authnContext Comparison="Whatever">Password</authnContext> </authRequest>',
);
});
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('<authnContext');
});
it('should treat whitespace-only values as empty custom authn contexts', () => {
const credentialToken = '__credentialToken__';
const customOptions = {
...serviceProviderOptions,
customAuthnContext: ' ',
};
const authorizeRequest = AuthorizeRequest.generate(customOptions, credentialToken);
expect(authorizeRequest.request).to.not.include('<authnContext');
});
});
});
describe('[LogoutRequest]', () => {
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<string> =>
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<unknown> | 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<string, string>) => {
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<void>) {
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/');
});
});
});