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
1268 lines
48 KiB
TypeScript
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/');
|
|
});
|
|
});
|
|
});
|