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') => `
${cert}
`;
const metadata = ({
keys = keyDescriptor(TEST_CERT),
slo = ``,
sso = ``,
nameIdFormats = '',
} = {}) => `
${keys}
${nameIdFormats}
${slo}
${sso}
`;
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: `` }));
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 = `
`;
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 = ``;
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 = ``;
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 = `
`;
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 = `
`;
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: 'urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress' }),
);
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 = `
urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress
urn:oasis:names:tc:SAML:2.0:nameid-format:transient`;
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 = `
`;
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 = `
`;
expect(() => parseIdpMetadata(saml1Only)).to.throw(InvalidIdpMetadataError);
});
it('rejects SP-only metadata (no IDPSSODescriptor)', () => {
const spOnly = `
`;
expect(() => parseIdpMetadata(spOnly)).to.throw(InvalidIdpMetadataError);
});
it('rejects a document whose root is not EntityDescriptor', () => {
expect(() => parseIdpMetadata('
nope')).to.throw(InvalidIdpMetadataError);
});
it('rejects a federation aggregate wrapping EntityDescriptors', () => {
const aggregate = `
`;
expect(() => parseIdpMetadata(aggregate)).to.throw(InvalidIdpMetadataError);
});
it('rejects an otherwise-valid document with an unclosed inner element', () => {
// xmldom auto-closes the unclosed into a structurally-complete document (every guard
// passes), so this only throws because the parser treats the `warning` it emits as fatal.
const unclosedInner = `
${TEST_CERT}
`;
expect(() => parseIdpMetadata(unclosedInner)).to.throw(InvalidIdpMetadataError);
});
});