snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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 1s
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 1s
This commit is contained in:
435
apps/meteor/tests/unit/server/lib/saml/data.ts
Normal file
435
apps/meteor/tests/unit/server/lib/saml/data.ts
Normal file
File diff suppressed because one or more lines are too long
43
apps/meteor/tests/unit/server/lib/saml/helpers.ts
Normal file
43
apps/meteor/tests/unit/server/lib/saml/helpers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SAMLDocumentType, SAMLPOSTEnvelope, SAMLRedirectEnvelope } from '../../../../../server/lib/saml/definition/SAMLEnvelope';
|
||||
|
||||
export function makeLogoutEnvelope<T extends SAMLDocumentType>(
|
||||
type: T,
|
||||
xml: string,
|
||||
signedContent?: string,
|
||||
signature?: string,
|
||||
): SAMLRedirectEnvelope<T> {
|
||||
return {
|
||||
binding: 'HTTP-Redirect',
|
||||
encodedDocument: xml,
|
||||
decodedDocument: xml,
|
||||
type,
|
||||
relayState: 'relayState',
|
||||
...(signedContent && { signedContent }),
|
||||
...(signature && {
|
||||
signature,
|
||||
sigAlg: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeLogoutRequestEnvelope(xml: string, signedContent?: string, signature?: string) {
|
||||
return makeLogoutEnvelope('SAMLRequest', xml, signedContent, signature);
|
||||
}
|
||||
|
||||
export function makeLogoutResponseEnvelope(xml: string, signedContent?: string, signature?: string) {
|
||||
return makeLogoutEnvelope('SAMLResponse', xml, signedContent, signature);
|
||||
}
|
||||
|
||||
export function makeLoginEnvelope<T extends SAMLDocumentType>(type: T, xml: string): SAMLPOSTEnvelope<T> {
|
||||
return {
|
||||
binding: 'HTTP-POST',
|
||||
encodedDocument: xml,
|
||||
decodedDocument: xml,
|
||||
type,
|
||||
relayState: 'relayState',
|
||||
};
|
||||
}
|
||||
|
||||
export function makeLoginResponseEnvelope(xml: string) {
|
||||
return makeLoginEnvelope('SAMLResponse', xml);
|
||||
}
|
||||
225
apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts
Normal file
225
apps/meteor/tests/unit/server/lib/saml/idpMetadata.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
|
||||
import { InvalidIdpMetadataError, parseIdpMetadata } from '../../../../../server/lib/saml/lib/parsers/IdpMetadata';
|
||||
|
||||
// Real self-signed cert (CN=idp.test) — the parser validates X.509, so fixtures must be real certs.
|
||||
const TEST_CERT =
|
||||
'MIIDBzCCAe+gAwIBAgIUZhaSm8CbG7FmgCQ2wi7+HLFQMokwDQYJKoZIhvcNAQELBQAwEzERMA8GA1UEAwwIaWRwLnRlc3QwHhcNMjYwNzE4MTk1NzIwWhcNMzYwNzE1MTk1NzIwWjATMREwDwYDVQQDDAhpZHAudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALbr9h69QN4dqnBTjnV7lWotx4FN2cktfL0Y2qVwQjw3q+OylaKCzio7Kvp2V2sQDsKDNgJnsLLP5TNjWqkXCfbbXRP/Iz3xlyyOoLNYJrZA4Sqn9/dFy6Chq5FSrMTwzqPCxx3nVDy/EpGUMknG7p3B0Ix18YFxQLsN5a/MpZXslrCusdl2LLnYkp6ztp44ZZlXHIaQhzeGnAZqzshvARAY9Ur41h4nSzpCgKVGACSi4LWRJeLc8/IXF+JM2MOR4GInCQLb1z31QPRRZ+3yWH3vKIfZ5YkPF6T0uPYw3hmhe0p7ECdOcSfckyeNT0WnvT8WKmeuOAnmmfqYPtgGBrkCAwEAAaNTMFEwHQYDVR0OBBYEFKQ/LDp2SiRg1jnOLLn/EkunXgaSMB8GA1UdIwQYMBaAFKQ/LDp2SiRg1jnOLLn/EkunXgaSMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBACGBYnhbQXkSTerNUN0Wu2sy1MfFUzMYTP3SaXaCMJQhlZXUxtnu1PYTuqCgNkgaiV6b/KUBLVkqX4BaDmP0O5SI3di/uYlAJsBUsDCdaXToKsnjpf9tMvci2TGmPMZMDx9Jbr37G0NR9vewTOFvTkcjBQoXVu5oFbr75EDmxu3hqe8KHiavnX8C57zzpZ8kn37ScP+0Zadu0VYCtKEzfNKp48rCOF3BtYugGcxQWdcvbqurNF9Fyk9laSl8cTLHFeDe6zdWig32n3nHzhANOcezQ/wsUY5XUfUpRUl90rend7zqNq0tFZzOiZDl1MjCs7HCYtTRKEkuwlxgXdvP5Tg=';
|
||||
|
||||
// Second distinct self-signed cert (CN=idp2.test), generated the same way, used to prove
|
||||
// document-order-first selection among multiple KeyDescriptors.
|
||||
const TEST_CERT_2 =
|
||||
'MIIDCTCCAfGgAwIBAgIUS4xN3PfQV0ROnf5nSMMFrN90pmQwDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJaWRwMi50ZXN0MB4XDTI2MDcxODIwMTAzN1oXDTM2MDcxNTIwMTAzN1owFDESMBAGA1UEAwwJaWRwMi50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAdfR0s3uE/Rnv59RRTwNRuYVTgJoBMoP+xxMhlm8FfZZTsBqv3vt2eqyLaxnChpS+UWnpf56zZpFrNgEAH+dEFPIkhqRL7nUmKSOCXu3nCTS5K03PEWJzbWcjgtbZ6A7Q7BE9bRmBqkkbUi22XQML06A2GIzPE8xliVhnYH5q4GRNIYf6Eh0eV88XZrsXvYgHC46/O/MUMLV5a6tCq2SPvbBKJSI01YQcE/W/C7NtiHUnmkpvWFrJ7Wn2akAwVeA8rL5SJ2rMbO1CB9JgaspjOGdkLqRc2LsZDO2Fmsk1BS6EGaEnxFIggq/LwQmdeu0nK8bmwLZOAB1MgGXbBq/wIDAQABo1MwUTAdBgNVHQ4EFgQUfgd/rj1KGMuiBR7byOQU0zl/bAMwHwYDVR0jBBgwFoAUfgd/rj1KGMuiBR7byOQU0zl/bAMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGxdEIVNeoN+BV82FbNGmFPXiQRO1FZk0pUqPczQ5TbN8XvTFWfY2YeawgVn0ec6+eA1M97znDAM5KPF+9RHdk5HRrjS+BX+uJcFKlbWEbZJShk4PookxLq6ELZQ5HPSGgGvyqSueeAI0RMg6aBcZOIzsVJduSeOQNAmkvppN6rTryFVBHvkyI9qYLu9bxZW/BvyUfXmvU+yPRDa98s/WzQowIKktNPTCoxwn6KfGLlmeH5nm9ra48aRKFCXuSVhiuph3lmD4IfbUDYijpfe3kLsmp1Up9bhVH7WUiyT/sD2NFBZp60UAohjWGolNXhosrUOELBiZVuR75gvLmLFV5Q==';
|
||||
|
||||
const REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
|
||||
const POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST';
|
||||
|
||||
const keyDescriptor = (cert: string, use = 'signing') => `
|
||||
<KeyDescriptor${use ? ` use="${use}"` : ''}>
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data><ds:X509Certificate>${cert}</ds:X509Certificate></ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</KeyDescriptor>`;
|
||||
|
||||
const metadata = ({
|
||||
keys = keyDescriptor(TEST_CERT),
|
||||
slo = `<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>`,
|
||||
sso = `<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>`,
|
||||
nameIdFormats = '',
|
||||
} = {}) => `<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
${keys}
|
||||
${nameIdFormats}
|
||||
${slo}
|
||||
${sso}
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`;
|
||||
|
||||
describe('parseIdpMetadata', () => {
|
||||
it('extracts cert, entry point and SLO url from typical metadata', () => {
|
||||
const result = parseIdpMetadata(metadata());
|
||||
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40));
|
||||
expect(result.entryPoint).to.equal('https://idp.test/sso');
|
||||
expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo');
|
||||
expect(result.warnings).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('omits the SLO url without warning when metadata has no SingleLogoutService', () => {
|
||||
const result = parseIdpMetadata(metadata({ slo: '' }));
|
||||
expect(result.idpSLORedirectURL).to.be.undefined;
|
||||
expect(result.entryPoint).to.equal('https://idp.test/sso');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding');
|
||||
});
|
||||
|
||||
it('uses the first signing cert (document order) and warns when there are multiple', () => {
|
||||
const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT) + keyDescriptor(TEST_CERT_2) }));
|
||||
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT.substring(0, 40));
|
||||
expect(result.cert).to.not.include(TEST_CERT_2.substring(0, 40));
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_multiple_certs');
|
||||
});
|
||||
|
||||
it('accepts a KeyDescriptor without a use attribute', () => {
|
||||
const result = parseIdpMetadata(metadata({ keys: keyDescriptor(TEST_CERT, '') }));
|
||||
expect(result.cert).to.be.a('string');
|
||||
});
|
||||
|
||||
it('skips a KeyDescriptor whose content is not a valid X.509 certificate and warns', () => {
|
||||
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') }));
|
||||
expect(result.cert).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert');
|
||||
});
|
||||
|
||||
it('does not warn about multiple certs when only one of them is valid', () => {
|
||||
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor(TEST_CERT_2) }));
|
||||
expect(result.cert).to.be.a('string').and.to.include(TEST_CERT_2.substring(0, 40));
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_valid_cert');
|
||||
});
|
||||
|
||||
it('warns that no valid cert was found when every KeyDescriptor is invalid', () => {
|
||||
const result = parseIdpMetadata(metadata({ keys: keyDescriptor('aGVsbG8gd29ybGQ=') + keyDescriptor('bm90IGEgY2VydA==') }));
|
||||
expect(result.cert).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_valid_cert');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_certs');
|
||||
});
|
||||
|
||||
it('omits the entry point when only HTTP-POST SSO bindings exist', () => {
|
||||
const result = parseIdpMetadata(metadata({ sso: `<SingleSignOnService Binding="${POST}" Location="https://idp.test/sso-post"/>` }));
|
||||
expect(result.entryPoint).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
|
||||
});
|
||||
|
||||
it('skips a Redirect SSO service with an invalid Location and uses the next valid one', () => {
|
||||
const sso = `
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="not a url"/>
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso-2"/>`;
|
||||
const result = parseIdpMetadata(metadata({ sso }));
|
||||
expect(result.entryPoint).to.equal('https://idp.test/sso-2');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_redirect_binding');
|
||||
});
|
||||
|
||||
it('omits the entry point and warns when the only Redirect SSO Location is not http(s)', () => {
|
||||
const sso = `<SingleSignOnService Binding="${REDIRECT}" Location="javascript:alert(1)"/>`;
|
||||
const result = parseIdpMetadata(metadata({ sso }));
|
||||
expect(result.entryPoint).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
|
||||
});
|
||||
|
||||
it('omits the SLO url and warns when no SingleLogoutService uses the HTTP-Redirect binding', () => {
|
||||
const slo = `<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>`;
|
||||
const result = parseIdpMetadata(metadata({ slo }));
|
||||
expect(result.idpSLORedirectURL).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_slo_redirect_binding');
|
||||
});
|
||||
|
||||
it('picks the Redirect SingleLogoutService even when a POST one comes first', () => {
|
||||
const slo = `
|
||||
<SingleLogoutService Binding="${POST}" Location="https://idp.test/slo-post"/>
|
||||
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo-redirect"/>`;
|
||||
const result = parseIdpMetadata(metadata({ slo }));
|
||||
expect(result.idpSLORedirectURL).to.equal('https://idp.test/slo-redirect');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_no_slo_redirect_binding');
|
||||
});
|
||||
|
||||
it('ignores services nested under Extensions instead of declared directly on the descriptor', () => {
|
||||
const sso = `
|
||||
<Extensions>
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://attacker.test/sso"/>
|
||||
<SingleLogoutService Binding="${REDIRECT}" Location="https://attacker.test/slo"/>
|
||||
</Extensions>
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>`;
|
||||
const result = parseIdpMetadata(metadata({ sso, slo: '' }));
|
||||
expect(result.entryPoint).to.equal('https://idp.test/sso');
|
||||
expect(result.idpSLORedirectURL).to.be.undefined;
|
||||
});
|
||||
|
||||
it('omits the entry point and warns when there is no SingleSignOnService element at all', () => {
|
||||
const result = parseIdpMetadata(metadata({ sso: '' }));
|
||||
expect(result.entryPoint).to.be.undefined;
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_no_redirect_binding');
|
||||
});
|
||||
|
||||
it('extracts the NameIDFormat', () => {
|
||||
const result = parseIdpMetadata(
|
||||
metadata({ nameIdFormats: '<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>' }),
|
||||
);
|
||||
expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress');
|
||||
expect(result.warnings).to.not.include('SAML_Metadata_warning_multiple_nameid_formats');
|
||||
});
|
||||
|
||||
it('uses the first NameIDFormat (document order) and warns when there are multiple', () => {
|
||||
const nameIdFormats = `
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress</NameIDFormat>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>`;
|
||||
const result = parseIdpMetadata(metadata({ nameIdFormats }));
|
||||
expect(result.identifierFormat).to.equal('urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress');
|
||||
expect(result.warnings).to.include('SAML_Metadata_warning_multiple_nameid_formats');
|
||||
});
|
||||
|
||||
it('picks the SAML 2.0 role when another protocol comes first', () => {
|
||||
const mixedRoles = `<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://mixed.test">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
|
||||
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest" Location="https://mixed.test/saml1/sso"/>
|
||||
</IDPSSODescriptor>
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://mixed.test/saml2/sso"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`;
|
||||
const result = parseIdpMetadata(mixedRoles);
|
||||
expect(result.entryPoint).to.equal('https://mixed.test/saml2/sso');
|
||||
});
|
||||
|
||||
it('rejects metadata whose only IDPSSODescriptor does not support SAML 2.0', () => {
|
||||
const saml1Only = `<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://saml1.test">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol">
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://saml1.test/sso"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`;
|
||||
expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError);
|
||||
});
|
||||
|
||||
it('rejects SP-only metadata (no IDPSSODescriptor)', () => {
|
||||
const spOnly = `<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://sp.test">
|
||||
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
|
||||
</EntityDescriptor>`;
|
||||
expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError);
|
||||
});
|
||||
|
||||
it('rejects a document whose root is not EntityDescriptor', () => {
|
||||
expect(() => parseIdpMetadata('<html><body>nope</body></html>')).to.throw(InvalidIdpMetadataError);
|
||||
});
|
||||
|
||||
it('rejects a federation aggregate wrapping EntityDescriptors', () => {
|
||||
const aggregate = `<?xml version="1.0"?>
|
||||
<EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<EntityDescriptor entityID="https://idp.test/metadata">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
</EntitiesDescriptor>`;
|
||||
expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError);
|
||||
});
|
||||
|
||||
it('rejects an otherwise-valid document with an unclosed inner element', () => {
|
||||
// xmldom auto-closes the unclosed <ds:KeyInfo> into a structurally-complete document (every guard
|
||||
// passes), so this only throws because the parser treats the `warning` it emits as fatal.
|
||||
const unclosedInner = `<?xml version="1.0"?>
|
||||
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp.test/metadata">
|
||||
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data><ds:X509Certificate>${TEST_CERT}</ds:X509Certificate></ds:X509Data>
|
||||
</KeyDescriptor>
|
||||
<SingleLogoutService Binding="${REDIRECT}" Location="https://idp.test/slo"/>
|
||||
<SingleSignOnService Binding="${REDIRECT}" Location="https://idp.test/sso"/>
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>`;
|
||||
expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError);
|
||||
});
|
||||
});
|
||||
74
apps/meteor/tests/unit/server/lib/saml/loginHandler.spec.ts
Normal file
74
apps/meteor/tests/unit/server/lib/saml/loginHandler.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { expect } from 'chai';
|
||||
import { describe, it, beforeEach } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const retrieveCredential = sinon.stub().resolves(null);
|
||||
const removeById = sinon.stub().resolves();
|
||||
const samlUtilsMock = {
|
||||
serviceProviders: [{ provider: 'test-saml' }] as any[],
|
||||
log: sinon.stub(),
|
||||
mapProfileToUserObject: sinon.stub(),
|
||||
events: { emit: sinon.stub() },
|
||||
};
|
||||
|
||||
const handler = sinon.stub();
|
||||
proxyquire.noCallThru().load('../../../../../server/lib/saml/loginHandler', {
|
||||
'@rocket.chat/models': {
|
||||
CredentialTokens: { removeById },
|
||||
},
|
||||
'meteor/accounts-base': {
|
||||
Accounts: {
|
||||
LoginCancelledError: { numericError: 403 },
|
||||
registerLoginHandler: (_name: string, fn: any) => {
|
||||
handler.callsFake(fn);
|
||||
},
|
||||
},
|
||||
},
|
||||
'meteor/meteor': {
|
||||
Meteor: { Error },
|
||||
},
|
||||
'./lib/SAML': {
|
||||
SAML: { retrieveCredential },
|
||||
},
|
||||
'./lib/Utils': {
|
||||
SAMLUtils: samlUtilsMock,
|
||||
},
|
||||
'../i18n': { i18n: { t: sinon.stub().returns('') } },
|
||||
'../logger/system': { SystemLogger: { error: sinon.stub() } },
|
||||
});
|
||||
|
||||
describe('SAML loginHandler', () => {
|
||||
beforeEach(() => {
|
||||
retrieveCredential.reset();
|
||||
retrieveCredential.resolves(null);
|
||||
removeById.reset();
|
||||
removeById.resolves();
|
||||
samlUtilsMock.serviceProviders = [{ provider: 'test-saml' }];
|
||||
});
|
||||
|
||||
it('should reject non-string credentialToken and never query the database (NoSQL injection prevention)', async () => {
|
||||
expect(await handler({ saml: true, credentialToken: { $gt: '' } })).to.be.undefined;
|
||||
expect(await handler({ saml: true, credentialToken: { $ne: null } })).to.be.undefined;
|
||||
expect(await handler({ saml: true, credentialToken: { $not: { $eq: '__nonexistent__' } } })).to.be.undefined;
|
||||
expect(await handler({ saml: true, credentialToken: 123 })).to.be.undefined;
|
||||
expect(await handler({ saml: true, credentialToken: ['a'] })).to.be.undefined;
|
||||
expect(await handler({ saml: true, credentialToken: null })).to.be.undefined;
|
||||
|
||||
expect(retrieveCredential.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should return undefined when no SAML providers are configured', async () => {
|
||||
samlUtilsMock.serviceProviders = [];
|
||||
|
||||
expect(await handler({ saml: true, credentialToken: 'valid-token' })).to.be.undefined;
|
||||
expect(retrieveCredential.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should delete the credential token after retrieval', async () => {
|
||||
await handler({ saml: true, credentialToken: 'token-to-delete' });
|
||||
|
||||
expect(removeById.calledOnce).to.be.true;
|
||||
expect(removeById.calledWith('token-to-delete')).to.be.true;
|
||||
});
|
||||
});
|
||||
1267
apps/meteor/tests/unit/server/lib/saml/server.tests.ts
Normal file
1267
apps/meteor/tests/unit/server/lib/saml/server.tests.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user