From 0b62819522f9e746740b9512977986fd0fb8b292 Mon Sep 17 00:00:00 2001
From: Thijs Kinkhorst <thijs@kinkhorst.com>
Date: Fri, 10 Sep 2021 15:46:38 +0000
Subject: [PATCH] Deduplicate IdP metadata generation by reusing SAML2
 getHostedMetadata facility

---
 tests/modules/saml/lib/Auth/Source/SPTest.php |   2 +-
 tests/modules/saml/lib/IdP/SAML2Test.php      | 539 ++++++++++++++++++
 www/saml2/idp/metadata.php                    | 188 +-----
 3 files changed, 543 insertions(+), 186 deletions(-)

diff --git a/tests/modules/saml/lib/Auth/Source/SPTest.php b/tests/modules/saml/lib/Auth/Source/SPTest.php
index d68d04dfd..bb5746072 100644
--- a/tests/modules/saml/lib/Auth/Source/SPTest.php
+++ b/tests/modules/saml/lib/Auth/Source/SPTest.php
@@ -863,7 +863,7 @@ class SPTest extends ClearStateTestCase
     }
 
     /**
-     * SP config option RegistationInfo is reflected in metadata
+     * SP config option RegistrationInfo is reflected in metadata
      */
     public function testMetadataHostedContainsRegistrationInfo(): void
     {
diff --git a/tests/modules/saml/lib/IdP/SAML2Test.php b/tests/modules/saml/lib/IdP/SAML2Test.php
index 66f664332..9f0b5906b 100644
--- a/tests/modules/saml/lib/IdP/SAML2Test.php
+++ b/tests/modules/saml/lib/IdP/SAML2Test.php
@@ -4,8 +4,12 @@ declare(strict_types=1);
 
 namespace SimpleSAML\Test\Module\saml\IdP;
 
+use InvalidArgumentException;
 use SimpleSAML\Configuration;
+use SimpleSAML\Error\Exception;
 use SimpleSAML\IdP;
+use SimpleSAML\Metadata\MetaDataStorageHandler;
+use SimpleSAML\Metadata\MetaDataStorageHandlerSerialize;
 use SimpleSAML\Module\saml\IdP\SAML2;
 use SimpleSAML\Test\Utils\ClearStateTestCase;
 
@@ -14,6 +18,15 @@ use SimpleSAML\Test\Utils\ClearStateTestCase;
  */
 class SAML2Test extends ClearStateTestCase
 {
+    /** @var string */
+    private const SECURITY = 'vendor/simplesamlphp/xml-security/tests/resources';
+
+    /** @var string */
+    public const CERT_KEY = '../' . self::SECURITY . '/certificates/rsa-pem/selfsigned.simplesamlphp.org.key';
+
+    /** @var string */
+    public const CERT_PUBLIC = '../' . self::SECURITY . '/certificates/rsa-pem/selfsigned.simplesamlphp.org.crt';
+
     /**
      * Default values for the state array expected to be generated at the start of logins
      * @var array
@@ -217,4 +230,530 @@ EOT;
 
         return $state;
     }
+
+    /**
+     * Perform needed setup to be able to provide an array config
+     * of IdP-hosted metadata and be able to query this back from
+     * getHostedMetadata(). Creates a storage handler to store this
+     * config and sets some minimum required fields.
+     *
+     * @param array $metadata IdP metadata entry as found in saml20-idp-hosted
+     * @param array $extraconfig Additional SimpleSAML global config to load
+     * @return array Output of the getHostedMetadata() method
+     */
+    private function idpMetadataHandlerHelper(array $metadata, array $extraconfig = []): array
+    {
+        Configuration::loadFromArray([
+            'metadata.sources' => [
+                ["type" => "serialize", "directory" => "/tmp"],
+            ],
+        ] + $extraconfig, '', 'simplesaml');
+        $metaHandler = new MetaDataStorageHandlerSerialize(['directory' => '/tmp']);
+
+        $metadata['entityid'] = 'urn:example:simplesaml:idp';
+        $metadata['certificate'] = self::CERT_PUBLIC;
+        $metadata['privatekey'] = self::CERT_KEY;
+
+        $metaHandler->saveMetadata($metadata['entityid'], 'saml20-idp-hosted', $metadata);
+
+        $_SERVER['REQUEST_URI'] = '/dummy';
+
+        return SAML2::getHostedMetadata($metadata['entityid']);
+    }
+
+    /**
+     * A minimally configured hosted IdP has all default fields with expected values.
+     */
+    public function testIdPGetHostedMetadataMinimal(): void
+    {
+        $md = [];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertIsArray($hostedMd);
+        $this->assertArrayHasKey('metadata-set', $hostedMd);
+        $this->assertEquals('saml20-idp-hosted', $hostedMd['metadata-set']);
+        $this->assertArrayHasKey('entityid', $hostedMd);
+        $this->assertEquals('urn:example:simplesaml:idp', $hostedMd['entityid']);
+
+        $this->assertArrayHasKey('NameIDFormat', $hostedMd);
+        $this->assertIsArray($hostedMd['NameIDFormat']);
+        $this->assertCount(1, $hostedMd['NameIDFormat']);
+        $this->assertEquals('urn:oasis:names:tc:SAML:2.0:nameid-format:transient', $hostedMd['NameIDFormat'][0]);
+
+        $this->assertArrayHasKey('SingleSignOnService', $hostedMd);
+        $this->assertIsArray($hostedMd['SingleSignOnService']);
+        $this->assertCount(1, $hostedMd['SingleSignOnService']);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+           'Location' => 'http://localhost/simplesaml/saml2/idp/SSOService.php'], $hostedMd['SingleSignOnService'][0]);
+        $this->assertArrayHasKey('SingleLogoutService', $hostedMd);
+        $this->assertIsArray($hostedMd['SingleLogoutService']);
+        $this->assertCount(1, $hostedMd['SingleLogoutService']);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 
+            'Location' => 'http://localhost/simplesaml/saml2/idp/SingleLogoutService.php'], $hostedMd['SingleLogoutService'][0]);
+
+        $this->assertArrayHasKey('keys', $hostedMd);
+        $this->assertIsArray($hostedMd['keys']);
+        $this->assertCount(1, $hostedMd['keys']);
+        $this->assertEquals('X509Certificate', $hostedMd['keys'][0]['type']);
+        $this->assertTrue($hostedMd['keys'][0]['signing']);
+        $this->assertTrue($hostedMd['keys'][0]['encryption']);
+        $this->assertEquals('', $hostedMd['keys'][0]['prefix']);
+        $this->assertStringStartsWith('MIICxDCCAi2gAwI', $hostedMd['keys'][0]['X509Certificate']);
+    }
+
+    public function testIdPGetHostedKeyRollover(): void
+    {
+        $md = ['new_certificate' => self::CERT_PUBLIC, 'new_privatekey' => self::CERT_KEY];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('keys', $hostedMd);
+        $this->assertIsArray($hostedMd['keys']);
+        $this->assertCount(2, $hostedMd['keys']);
+        $this->assertEquals('X509Certificate', $hostedMd['keys'][0]['type']);
+        $this->assertTrue($hostedMd['keys'][0]['signing']);
+        $this->assertTrue($hostedMd['keys'][0]['encryption']);
+        $this->assertEquals('new_', $hostedMd['keys'][0]['prefix']);
+        $this->assertStringStartsWith('MIICxDCCAi2gAwI', $hostedMd['keys'][0]['X509Certificate']);
+        $this->assertEquals('X509Certificate', $hostedMd['keys'][1]['type']);
+        $this->assertTrue($hostedMd['keys'][1]['signing']);
+        $this->assertFalse($hostedMd['keys'][1]['encryption']);
+        $this->assertEquals('', $hostedMd['keys'][1]['prefix']);
+        $this->assertStringStartsWith('MIICxDCCAi2gAwI', $hostedMd['keys'][1]['X509Certificate']);
+    }
+
+    public function testIdPGetHostedHttpsCertificate(): void
+    {
+        $md = ['https.certificate' => self::CERT_PUBLIC];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('keys', $hostedMd);
+        $this->assertIsArray($hostedMd['keys']);
+        $this->assertCount(2, $hostedMd['keys']);
+        $this->assertEquals('X509Certificate', $hostedMd['keys'][0]['type']);
+        $this->assertTrue($hostedMd['keys'][0]['signing']);
+        $this->assertTrue($hostedMd['keys'][0]['encryption']);
+        $this->assertEquals('', $hostedMd['keys'][0]['prefix']);
+        $this->assertStringStartsWith('MIICxDCCAi2gAwI', $hostedMd['keys'][0]['X509Certificate']);
+        $this->assertEquals('X509Certificate', $hostedMd['keys'][1]['type']);
+        $this->assertTrue($hostedMd['keys'][1]['signing']);
+        $this->assertFalse($hostedMd['keys'][1]['encryption']);
+        $this->assertEquals('https.', $hostedMd['keys'][1]['prefix']);
+        $this->assertStringStartsWith('MIICxDCCAi2gAwI', $hostedMd['keys'][1]['X509Certificate']);
+    }
+
+    public function testIdPGetHostedMetadataArtifact(): void
+    {
+        $md = ['saml20.sendartifact' => true];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('ArtifactResolutionService', $hostedMd);
+        $this->assertIsArray($hostedMd['ArtifactResolutionService']);
+        $this->assertCount(1, $hostedMd['ArtifactResolutionService']);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP', 'index' => 0,
+           'Location' => 'http://localhost/simplesaml/saml2/idp/ArtifactResolutionService.php'], $hostedMd['ArtifactResolutionService'][0]);
+    }
+
+    public function testIdPGetHostedMetadataHolderOfKey(): void
+    {
+        $md = ['saml20.hok.assertion' => true];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('SingleSignOnService', $hostedMd);
+        $this->assertIsArray($hostedMd['SingleSignOnService']);
+        $this->assertCount(2, $hostedMd['SingleSignOnService']);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:profiles:holder-of-key:SSO:browser',
+            'Location' => 'http://localhost/simplesaml/saml2/idp/SSOService.php',
+            'hoksso:ProtocolBinding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'], $hostedMd['SingleSignOnService'][0]);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+           'Location' => 'http://localhost/simplesaml/saml2/idp/SSOService.php'], $hostedMd['SingleSignOnService'][1]);
+    }
+
+    public function testIdPGetHostedMetadataECP(): void
+    {
+        $md = ['saml20.ecp' => true];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('SingleSignOnService', $hostedMd);
+        $this->assertIsArray($hostedMd['SingleSignOnService']);
+        $this->assertCount(2, $hostedMd['SingleSignOnService']);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+           'Location' => 'http://localhost/simplesaml/saml2/idp/SSOService.php'], $hostedMd['SingleSignOnService'][0]);
+        $this->assertEquals(['Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP', 'index' => 0,
+           'Location' => 'http://localhost/simplesaml/saml2/idp/SSOService.php'], $hostedMd['SingleSignOnService'][1]);
+    }
+
+    /**
+     * NameIDFormat option can be specified as string or array
+     */
+    public function testIdPGetHostedNameIdFormat(): void
+    {
+        $md = [
+            'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+        ];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('NameIDFormat', $hostedMd);
+        $this->assertIsArray($hostedMd['NameIDFormat']);
+        $this->assertCount(1, $hostedMd['NameIDFormat']);
+        $this->assertEquals('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', $hostedMd['NameIDFormat'][0]);
+
+        $md = [
+            'NameIDFormat' => [
+                'urn:oasis:names:tc:SAML:2.0:nameid-format:transient',
+                'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+            ],
+        ];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('NameIDFormat', $hostedMd);
+        $this->assertIsArray($hostedMd['NameIDFormat']);
+        $this->assertCount(2, $hostedMd['NameIDFormat']);
+        $this->assertEquals('urn:oasis:names:tc:SAML:2.0:nameid-format:transient', $hostedMd['NameIDFormat'][0]);
+        $this->assertEquals('urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', $hostedMd['NameIDFormat'][1]);
+    }
+
+    public function testIdPGetHostedScopes(): void
+    {
+        $md = [
+            'NameIDFormat' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
+            'scope' => ['sec.nl','^.*\.surfnet\.nl$'],
+            'unknown-option' => 'something',
+        ];
+        $hostedMd = $this->idpMetadataHandlerHelper($md);
+
+        $this->assertArrayHasKey('scope', $hostedMd);
+        $this->assertEquals(['sec.nl','^.*\.surfnet\.nl$'], $hostedMd['scope']);
+        // Unknown options are ignored
+        $this->assertArrayNotHasKey('unknown-option', $hostedMd);
+    }
+
+    /**
+     * IdP config option Organization* are reflected in metadata
+     */
+    public function testMetadataHostedOrganizationData(): void
+    {
+        $config = [
+                'OrganizationName' => [
+                    'en' => 'Voorbeeld Organisatie Foundation b.a.',
+                    'nl' => 'Stichting Voorbeeld Organisatie b.a.',
+                ],
+                'OrganizationDisplayName' => [
+                    'en' => 'Example organization',
+                    'nl' => 'Voorbeeldorganisatie',
+                ],
+                'OrganizationURL' => [
+                    'en' => 'https://example.com',
+                    'nl' => 'https://example.com/nl',
+                ],
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertEquals('Voorbeeld Organisatie Foundation b.a.', $md['OrganizationName']['en']);
+        $this->assertEquals('Voorbeeldorganisatie', $md['OrganizationDisplayName']['nl']);
+        $this->assertEquals('https://example.com/nl', $md['OrganizationURL']['nl']);
+    }
+
+    /**
+     * IdP config option Organization* without explicit DisplayName are reflected in metadata
+     */
+    public function testMetadataHostedOrganizationDataDefaultForDisplayNameIsName(): void
+    {
+        $config = [
+                'OrganizationName' => [
+                    'nl' => 'Stichting Voorbeeld Organisatie b.a.',
+                ],
+                'OrganizationURL' => [
+                    'nl' => 'https://example.com/nl',
+                ],
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertEquals('Stichting Voorbeeld Organisatie b.a.', $md['OrganizationName']['nl']);
+        $this->assertEquals('Stichting Voorbeeld Organisatie b.a.', $md['OrganizationDisplayName']['nl']);
+        $this->assertEquals('https://example.com/nl', $md['OrganizationURL']['nl']);
+    }
+
+    /**
+     * IdP config option Organization* without URL is rejected with an Exception
+     */
+    public function testMetadataHostedOrganizationURLMissingRaisesException(): void
+    {
+        $config = [
+                'OrganizationName' => [
+                    'nl' => 'Stichting Voorbeeld Organisatie b.a.',
+                ],
+                'OrganizationDisplayName' => [
+                    'nl' => 'Voorbeeldorganisatie',
+                ],
+            ];
+
+        $this->expectException(Exception::class);
+        $this->expectExceptionMessage('If OrganizationName is set, OrganizationURL must also be set.');
+        $md = $this->idpMetadataHandlerHelper($config);
+    }
+
+    /**
+     * IdP config option for entity attributes is reflected in metadata
+     */
+    public function testMetadataHostedEntityAttributes(): void
+    {
+        $ea = ['{urn:simplesamlphp:v1}foo' => ['bar']];
+        $config = [
+            'EntityAttributes' => $ea,
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('EntityAttributes', $md);
+        $this->assertEquals($ea, $md['EntityAttributes']);
+        $this->assertArrayNotHasKey('hide.from.discovery', $md);
+
+        // Special case category causes extra field to be set
+        $ea = ['http://macedir.org/entity-category' => ['http://refeds.org/category/hide-from-discovery']];
+        $config = [
+            'EntityAttributes' => $ea,
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('EntityAttributes', $md);
+        $this->assertEquals($ea, $md['EntityAttributes']);
+        $this->assertArrayHasKey('hide.from.discovery', $md);
+        $this->assertTrue($md['hide.from.discovery']);
+    }
+
+    /**
+     * IdP config option for entity attribute extensions is reflected in metadata
+     */
+    public function testMetadataHostedEntityExtensions(): void
+    {
+        $dom = \SAML2\DOMDocumentFactory::create();
+        $republishRequest = $dom->createElementNS('http://eduid.cz/schema/metadata/1.0', 'eduidmd:RepublishRequest');
+        $republishTarget = $dom->createElementNS('http://eduid.cz/schema/metadata/1.0', 'eduidmd:RepublishTarget', 'http://edugain.org/');
+        $republishRequest->appendChild($republishTarget);
+        $ext = [new \SAML2\XML\Chunk($republishRequest)];
+
+        $config = [
+            'saml:Extensions' => $ext,
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('saml:Extensions', $md);
+        $this->assertCount(1, $md['saml:Extensions']);
+        $this->assertInstanceOf(\SAML2\XML\Chunk::class, $md['saml:Extensions'][0]);
+        $this->assertEquals('http://edugain.org/', $md['saml:Extensions'][0]->getXML()->firstChild->firstChild->textContent);
+    }
+
+    /**
+     * IdP config option for UIInfo is reflected in metadata
+     */
+    public function testMetadataHostedUIInfo(): void
+    {
+        $config = [
+            'UIInfo' => [
+                'DisplayName' => [
+                    'en' => 'English name',
+                    'es' => 'Nombre en Español'
+                 ],
+                 'Description' => [
+                    'en' => 'English description',
+                    'es' => 'Descripción en Español'
+                 ],
+                 'Logo' => [
+                     [
+                         'url'    => 'http://example.com/logo1.png',
+                         'height' => 200,
+                         'width'  => 400,
+                         'lang'   => 'en',
+                     ],
+                ],
+            ],
+            'DiscoHints' => [
+                'IPHint'          => ['130.59.0.0/16', '2001:620::0/96'],
+                'DomainHint'      => ['example.com', 'www.example.com'],
+                'GeolocationHint' => ['geo:47.37328,8.531126', 'geo:19.34343,12.342514'],
+            ],
+        ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('UIInfo', $md);
+        $this->assertIsArray($md['UIInfo']);
+        $this->assertEquals('Descripción en Español', $md['UIInfo']['Description']['es']);
+        $this->assertEquals(200, $md['UIInfo']['Logo'][0]['height']);
+        $this->assertEquals('geo:19.34343,12.342514', $md['DiscoHints']['GeolocationHint'][1]);
+    }
+
+    /**
+     * IdP config option RegistrationInfo is reflected in metadata
+     */
+    public function testMetadataHostedContainsRegistrationInfo(): void
+    {
+        $config = [
+                'RegistrationInfo' => [
+                    'authority' => 'urn:mace:sp.example.org',
+                    'instant' => '2008-01-17T11:28:03.577Z',
+                    'policies' => ['en' => 'http://sp.example.org/policy', 'es' => 'http://sp.example.org/politica'],
+                ],
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('RegistrationInfo', $md);
+        $reginfo = $md['RegistrationInfo'];
+        $this->assertIsArray($reginfo);
+        $this->assertEquals('urn:mace:sp.example.org', $reginfo['authority']);
+        $this->assertEquals('2008-01-17T11:28:03.577Z', $reginfo['instant']);
+        $this->assertIsArray($reginfo['policies']);
+        $this->assertCount(2, $reginfo['policies']);
+        $this->assertEquals('http://sp.example.org/politica', $reginfo['policies']['es']);
+    }
+
+    /**
+     * IdP config options wrt signing are reflected in metadata
+     */
+    public function testMetadataHostedSigning(): void
+    {
+        $config = [
+                'redirect.validate' => true,
+                'validate.authnrequest' => true,
+            ];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('sign.authnrequest', $md);
+        $this->assertArrayHasKey('redirect.sign', $md);
+        $this->assertTrue($md['sign.authnrequest']);
+        $this->assertTrue($md['redirect.sign']);
+        $this->assertArrayNotHasKey('redirect.validate', $md);
+    }
+
+    /**
+     * Contacts in IdP hosted config appear in metadata
+     */
+    public function testMetadataHostedContacts(): void
+    {
+        $config = ['contacts' => [
+            [
+               'contactType'       => 'other',
+               'emailAddress'      => 'csirt@example.com',
+               'surName'           => 'CSIRT',
+               'telephoneNumber'   => '+31SECOPS',
+               'company'           => 'Acme Inc',
+               'attributes'        => [
+                   'xmlns:remd'        => 'http://refeds.org/metadata',
+                   'remd:contactType'  => 'http://refeds.org/metadata/contactType/security',
+               ],
+            ],
+            [
+               'contactType'       => 'administrative',
+               'emailAddress'      => 'j.doe@example.edu',
+               'givenName'         => 'Jane',
+               'surName'           => 'Doe',
+            ],
+        ]];
+        $md = $this->idpMetadataHandlerHelper($config);
+
+        $this->assertArrayHasKey('contacts', $md);
+        $this->assertIsArray($md['contacts']);
+        $this->assertCount(2, $md['contacts']);
+
+        $contacts = $md['contacts'];
+        $contact = $md['contacts'][0];
+
+        $this->assertIsArray($contact);
+        $this->assertEquals('other', $contact['contactType']);
+        $this->assertEquals('CSIRT', $contact['surName']);
+        $this->assertArrayNotHasKey('givenName', $contact);
+        $this->assertEquals('+31SECOPS', $contact['telephoneNumber']);
+        $this->assertEquals('Acme Inc', $contact['company']);
+        $this->assertIsArray($contact['attributes']);
+        $attrs = [
+            'xmlns:remd' => 'http://refeds.org/metadata',
+            'remd:contactType' => 'http://refeds.org/metadata/contactType/security'
+        ];
+        $this->assertEquals($attrs, $contact['attributes']);
+
+        $contact = $md['contacts'][1];
+        $this->assertIsArray($contact);
+        $this->assertEquals('administrative', $contact['contactType']);
+        $this->assertEquals('j.doe@example.edu', $contact['emailAddress']);
+        $this->assertArrayNotHasKey('attributes', $contact);
+    }
+
+    /**
+     * A globally set tech contact also appears in IdP hosted metadata
+     */
+    public function testMetadataHostedContactsIncludesGlobalTechContact(): void
+    {
+        $globalConfig = [
+            'technicalcontact_email' => 'someone.somewhere@example.org',
+            'technicalcontact_name' => 'Someone von Somewhere',
+        ];
+
+        $config = ['contacts' => [
+            [
+               'contactType'       => 'technical',
+               'emailAddress'      => 'j.doe@example.edu',
+               'givenName'         => 'Jane',
+               'surName'           => 'Doe',
+            ],
+        ]];
+        $md = $this->idpMetadataHandlerHelper($config, $globalConfig);
+
+        $this->assertArrayHasKey('contacts', $md);
+        $this->assertIsArray($md['contacts']);
+        $this->assertCount(2, $md['contacts']);
+
+        $contacts = $md['contacts'];
+        $contact = $md['contacts'][0];
+
+        $this->assertIsArray($contact);
+        $this->assertEquals('technical', $contact['contactType']);
+        $this->assertEquals('Doe', $contact['surName']);
+
+        $contact = $md['contacts'][1];
+        $this->assertIsArray($contact);
+        $this->assertEquals('technical', $contact['contactType']);
+        $this->assertEquals('someone.somewhere@example.org', $contact['emailAddress']);
+        $this->assertEquals('Someone von Somewhere', $contact['givenName']);
+        $this->assertArrayNotHasKey('surName', $contact);
+    }
+
+    /**
+     * The special value na@example.org global tech contact is not included in IdP metadata
+     */
+    public function testMetadataHostedContactsSkipsNAGlobalTechContact(): void
+    {
+        $globalConfig = [
+            'technicalcontact_email' => 'na@example.org',
+            'technicalcontact_name' => 'Someone von Somewhere',
+	];
+
+        $config = ['contacts' => [
+            [
+               'contactType'       => 'technical',
+               'emailAddress'      => 'j.doe@example.edu',
+               'surName'           => 'Doe',
+            ],
+        ]];
+        $md = $this->idpMetadataHandlerHelper($config, $globalConfig);
+
+        $this->assertCount(1, $md['contacts']);
+        $this->assertEquals('j.doe@example.edu', $md['contacts'][0]['emailAddress']);
+    }
+
+    /**
+     * Contacts in IdP hosted of unknown type throws Exceptiona
+     */
+    public function testMetadataHostedContactsUnknownTypeThrowsException(): void
+    {
+        $config = ['contacts' => [
+            [
+               'contactType'       => 'anything',
+               'emailAddress'      => 'j.doe@example.edu',
+               'givenName'         => 'Jane',
+               'surName'           => 'Doe',
+            ],
+        ]];
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('"contactType" is mandatory and must be one of');
+        $md = $this->idpMetadataHandlerHelper($config);
+    }
 }
diff --git a/www/saml2/idp/metadata.php b/www/saml2/idp/metadata.php
index 68404390f..7cf7883b6 100644
--- a/www/saml2/idp/metadata.php
+++ b/www/saml2/idp/metadata.php
@@ -9,6 +9,7 @@ use SimpleSAML\Assert\Assert;
 use SimpleSAML\Configuration;
 use SimpleSAML\Error;
 use SimpleSAML\Module;
+use SimpleSAML\Module\saml\IdP\SAML2 as SAML2_IdP;
 use SimpleSAML\Utils;
 use SimpleSAML\Utils\Config\Metadata as Metadata;
 
@@ -29,190 +30,7 @@ $metadata = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler();
 try {
     $idpentityid = isset($_GET['idpentityid']) ?
         $_GET['idpentityid'] : $metadata->getMetaDataCurrentEntityID('saml20-idp-hosted');
-    $idpmeta = $metadata->getMetaDataConfig($idpentityid, 'saml20-idp-hosted');
-
-    $cryptoUtils = new Utils\Crypto();
-    $availableCerts = [];
-    $keys = [];
-
-    $certInfo = $cryptoUtils->loadPublicKey($idpmeta, false, 'new_');
-    if ($certInfo !== null) {
-        $availableCerts['new_idp.crt'] = $certInfo;
-        $keys[] = [
-            'type'            => 'X509Certificate',
-            'signing'         => true,
-            'encryption'      => true,
-            'X509Certificate' => $certInfo['certData'],
-        ];
-        $hasNewCert = true;
-    } else {
-        $hasNewCert = false;
-    }
-
-    $certInfo = $cryptoUtils->loadPublicKey($idpmeta, true);
-    $availableCerts['idp.crt'] = $certInfo;
-    $keys[] = [
-        'type'            => 'X509Certificate',
-        'signing'         => true,
-        'encryption'      => ($hasNewCert ? false : true),
-        'X509Certificate' => $certInfo['certData'],
-    ];
-
-    if ($idpmeta->hasValue('https.certificate')) {
-        $httpsCert = $cryptoUtils->loadPublicKey($idpmeta, true, 'https.');
-        Assert::notNull($httpsCert['certData']);
-        $availableCerts['https.crt'] = $httpsCert;
-        $keys[] = [
-            'type'            => 'X509Certificate',
-            'signing'         => true,
-            'encryption'      => false,
-            'X509Certificate' => $httpsCert['certData'],
-        ];
-    }
-
-    $metaArray = [
-        'metadata-set' => 'saml20-idp-remote',
-        'entityid'     => $idpentityid,
-    ];
-
-    $ssob = $metadata->getGenerated('SingleSignOnServiceBinding', 'saml20-idp-hosted');
-    $slob = $metadata->getGenerated('SingleLogoutServiceBinding', 'saml20-idp-hosted');
-    $ssol = $metadata->getGenerated('SingleSignOnService', 'saml20-idp-hosted');
-    $slol = $metadata->getGenerated('SingleLogoutService', 'saml20-idp-hosted');
-
-    if (is_array($ssob)) {
-        foreach ($ssob as $binding) {
-            $metaArray['SingleSignOnService'][] = [
-                'Binding'  => $binding,
-                'Location' => $ssol,
-            ];
-        }
-    } else {
-        $metaArray['SingleSignOnService'][] = [
-            'Binding'  => $ssob,
-            'Location' => $ssol,
-        ];
-    }
-
-    if (is_array($slob)) {
-        foreach ($slob as $binding) {
-            $metaArray['SingleLogoutService'][] = [
-                'Binding'  => $binding,
-                'Location' => $slol,
-            ];
-        }
-    } else {
-        $metaArray['SingleLogoutService'][] = [
-            'Binding'  => $slob,
-            'Location' => $slol,
-        ];
-    }
-
-    if (count($keys) === 1) {
-        $metaArray['certData'] = $keys[0]['X509Certificate'];
-    } else {
-        $metaArray['keys'] = $keys;
-    }
-
-    if ($idpmeta->getBoolean('saml20.sendartifact', false)) {
-        // Artifact sending enabled
-        $metaArray['ArtifactResolutionService'][] = [
-            'index'    => 0,
-            'Location' => $httpUtils->getBaseURL() . 'saml2/idp/ArtifactResolutionService.php',
-            'Binding'  => Constants::BINDING_SOAP,
-        ];
-    }
-
-    if ($idpmeta->getBoolean('saml20.hok.assertion', false)) {
-        // Prepend HoK SSO Service endpoint.
-        array_unshift($metaArray['SingleSignOnService'], [
-            'hoksso:ProtocolBinding' => Constants::BINDING_HTTP_REDIRECT,
-            'Binding'                => Constants::BINDING_HOK_SSO,
-            'Location'               => $httpUtils->getBaseURL() . 'saml2/idp/SSOService.php'
-        ]);
-    }
-
-    if ($idpmeta->getBoolean('saml20.ecp', false)) {
-        $metaArray['SingleSignOnService'][] = [
-            'index' => 0,
-            'Binding'  => Constants::BINDING_SOAP,
-            'Location' => $httpUtils->getBaseURL() . 'saml2/idp/SSOService.php',
-        ];
-    }
-
-    $metaArray['NameIDFormat'] = $idpmeta->getArrayizeString(
-        'NameIDFormat',
-        'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
-    );
-
-    if ($idpmeta->hasValue('OrganizationName')) {
-        $metaArray['OrganizationName'] = $idpmeta->getLocalizedString('OrganizationName');
-        $metaArray['OrganizationDisplayName'] = $idpmeta->getLocalizedString(
-            'OrganizationDisplayName',
-            $metaArray['OrganizationName']
-        );
-
-        if (!$idpmeta->hasValue('OrganizationURL')) {
-            throw new Error\Exception(
-                'If OrganizationName is set, OrganizationURL must also be set.'
-            );
-        }
-        $metaArray['OrganizationURL'] = $idpmeta->getLocalizedString('OrganizationURL');
-    }
-
-    if ($idpmeta->hasValue('scope')) {
-        $metaArray['scope'] = $idpmeta->getArray('scope');
-    }
-
-    if ($idpmeta->hasValue('EntityAttributes')) {
-        $metaArray['EntityAttributes'] = $idpmeta->getArray('EntityAttributes');
-
-        // check for entity categories
-        if (Metadata::isHiddenFromDiscovery($metaArray)) {
-            $metaArray['hide.from.discovery'] = true;
-        }
-    }
-
-    if ($idpmeta->hasValue('saml:Extensions')) {
-        $metaArray['saml:Extensions'] = $idpmeta->getArray('saml:Extensions');
-    }
-
-    if ($idpmeta->hasValue('UIInfo')) {
-        $metaArray['UIInfo'] = $idpmeta->getArray('UIInfo');
-    }
-
-    if ($idpmeta->hasValue('DiscoHints')) {
-        $metaArray['DiscoHints'] = $idpmeta->getArray('DiscoHints');
-    }
-
-    if ($idpmeta->hasValue('RegistrationInfo')) {
-        $metaArray['RegistrationInfo'] = $idpmeta->getArray('RegistrationInfo');
-    }
-
-    if ($idpmeta->hasValue('validate.authnrequest')) {
-        $metaArray['sign.authnrequest'] = $idpmeta->getBoolean('validate.authnrequest');
-    }
-
-    if ($idpmeta->hasValue('redirect.validate')) {
-        $metaArray['redirect.sign'] = $idpmeta->getBoolean('redirect.validate');
-    }
-
-    if ($idpmeta->hasValue('contacts')) {
-        $contacts = $idpmeta->getArray('contacts');
-        foreach ($contacts as $contact) {
-            $metaArray['contacts'][] = Metadata::getContact($contact);
-        }
-    }
-
-    $technicalContactEmail = $config->getString('technicalcontact_email', false);
-    if ($technicalContactEmail && $technicalContactEmail !== 'na@example.org') {
-        $techcontact = [
-            'emailAddress' => $technicalContactEmail,
-            'name' => $config->getString('technicalcontact_name', null),
-            'contactType' => 'technical',
-        ];
-        $metaArray['contacts'][] = Metadata::getContact($techcontact);
-    }
+    $metaArray = SAML2_IdP::getHostedMetadata($idpentityid);
 
     $metaBuilder = new \SimpleSAML\Metadata\SAMLBuilder($idpentityid);
     $metaBuilder->addMetadataIdP20($metaArray);
@@ -223,7 +41,7 @@ try {
     $metaflat = '$metadata[' . var_export($idpentityid, true) . '] = ' . VarExporter::export($metaArray) . ';';
 
     // sign the metadata if enabled
-    $metaxml = \SimpleSAML\Metadata\Signer::sign($metaxml, $idpmeta->toArray(), 'SAML 2 IdP');
+    $metaxml = \SimpleSAML\Metadata\Signer::sign($metaxml, $metaArray, 'SAML 2 IdP');
 
     if (array_key_exists('output', $_GET) && $_GET['output'] == 'xhtml') {
         $t = new \SimpleSAML\XHTML\Template($config, 'metadata.tpl.php', 'admin');
-- 
GitLab