diff --git a/composer.json b/composer.json index ecbc271d07cb7abd4c58bd23fdcbaf391cc95a1a..007e580d347dc588a3d1d5f63c0cd4fb2a9154a4 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ }, "autoload-dev": { "psr-4": { - "SimpleSAML\\Test\\": "tests" + "SimpleSAML\\Test\\": ["tests", "tests/lib/SimpleSAML"] }, "files": ["tests/_autoload_modules.php"] }, diff --git a/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php b/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php index 00b5674be01422386ac0c31a177f3a81a01ecf1c..9c147670923d0daaa8773fd47eac1af78f05139b 100644 --- a/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php +++ b/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php @@ -8,6 +8,8 @@ use SimpleSAML\Configuration; use SimpleSAML\Error; use SimpleSAML\Logger; use SimpleSAML\Utils; +use SimpleSAML\Error\MetadataNotFound; +use SimpleSAML\Utils\ClearableState; /** * This file defines a class for metadata handling. @@ -260,6 +262,37 @@ class MetaDataStorageHandler implements \SimpleSAML\Utils\ClearableState return null; } + /** + * This function loads the metadata for entity IDs in $entityIds. It is returned as an associative array + * where the key is the entity id. An empty array may be returned if no matching entities were found + * @param array $entityIds The entity ids to load + * @param string $set The set we want to get metadata from. + * @return array An associative array with the metadata for the requested entities, if found. + */ + public function getMetaDataForEntities(array $entityIds, $set) + { + $result = []; + foreach ($this->sources as $source) { + $srcList = $source->getMetaDataForEntities($entityIds, $set); + foreach ($srcList as $key => $le) { + if (array_key_exists('expire', $le)) { + if ($le['expire'] < time()) { + unset($srcList[$key]); + \SimpleSAML\Logger::warning( + "Dropping metadata entity ".var_export($key, true).", expired ". + \SimpleSAML\Utils\Time::generateTimestamp($le['expire'])."." + ); + continue; + } + } + // We found the entity id so remove it from the list that needs resolving + unset($entityIds[array_search($key, $entityIds)]); + } + $result = array_merge($srcList, $result); + } + + return $result; + } /** * This function looks up the metadata for the given entity id in the given set. It will throw an diff --git a/lib/SimpleSAML/Metadata/MetaDataStorageHandlerSerialize.php b/lib/SimpleSAML/Metadata/MetaDataStorageHandlerSerialize.php index 0d7474e3dd7bfad84ce1f0247e0a94854d31320c..a41663fcc9cc5bd7eb794ab619ed95d02c3af522 100644 --- a/lib/SimpleSAML/Metadata/MetaDataStorageHandlerSerialize.php +++ b/lib/SimpleSAML/Metadata/MetaDataStorageHandlerSerialize.php @@ -290,4 +290,16 @@ class MetaDataStorageHandlerSerialize extends MetaDataStorageSource ); } } + + /** + * This function loads the metadata for entity IDs in $entityIds. It is returned as an associative array + * where the key is the entity id. An empty array may be returned if no matching entities were found + * @param array $entityIds The entity ids to load + * @param string $set The set we want to get metadata from. + * @return array An associative array with the metadata for the requested entities, if found. + */ + public function getMetaDataForEntities(array $entityIds, $set) + { + return $this->getMetaDataForEntitiesIndividually($entityIds, $set); + } } diff --git a/lib/SimpleSAML/Metadata/MetaDataStorageSource.php b/lib/SimpleSAML/Metadata/MetaDataStorageSource.php index 81ffd3b12ec190674bc2974ba6a45aeb4ef667a1..3ec6c0b555af6fabbf98b54a5a30253ed93833b7 100644 --- a/lib/SimpleSAML/Metadata/MetaDataStorageSource.php +++ b/lib/SimpleSAML/Metadata/MetaDataStorageSource.php @@ -57,7 +57,7 @@ abstract class MetaDataStorageSource * * @param array $sourceConfig Associative array with the configuration for this metadata source. * - * @return mixed An instance of a metadata source with the given configuration. + * @return \SimpleSAML\Metadata\MetaDataStorageSource An instance of a metadata source with the given configuration. * * @throws \Exception If the metadata source type is invalid. */ @@ -245,6 +245,44 @@ abstract class MetaDataStorageSource return null; } + /** + * This function loads the metadata for entity IDs in $entityIds. It is returned as an associative array + * where the key is the entity id. An empty array may be returned if no matching entities were found. + * Subclasses should override if their getMetadataSet returns nothing or is slow. Subclasses may want to + * delegate to getMetaDataForEntitiesIndividually if loading entities one at a time is faster. + * @param array $entityIds The entity ids to load + * @param string $set The set we want to get metadata from. + * @return array An associative array with the metadata for the requested entities, if found. + */ + public function getMetaDataForEntities(array $entityIds, $set) + { + if (count($entityIds) === 1) { + return $this->getMetaDataForEntitiesIndividually($entityIds, $set); + } + $entities = $this->getMetadataSet($set); + return array_intersect_key($entities, array_flip($entityIds)); + } + + /** + * Loads metadata entities one at a time, rather than the default implementation of loading all entities + * and filtering. + * @see MetaDataStorageSource::getMetaDataForEntities() + * @param array $entityIds The entity ids to load + * @param string $set The set we want to get metadata from. + * @return array An associative array with the metadata for the requested entities, if found. + */ + protected function getMetaDataForEntitiesIndividually(array $entityIds, $set) + { + $entities = []; + foreach ($entityIds as $entityId) { + $metadata = $this->getMetaData($entityId, $set); + if ($metadata !== null) { + $entities[$entityId] = $metadata; + } + } + return $entities; + } + /** * This method returns the full metadata set for a given entity id or null if the entity id cannot be found * in the given metadata set. diff --git a/lib/SimpleSAML/Metadata/Sources/MDQ.php b/lib/SimpleSAML/Metadata/Sources/MDQ.php index bd47dfb093fb4d76ef3b863a8cac5b18f9211de8..399975d5d86e5def0e8172a63fee44976e690a19 100644 --- a/lib/SimpleSAML/Metadata/Sources/MDQ.php +++ b/lib/SimpleSAML/Metadata/Sources/MDQ.php @@ -346,4 +346,16 @@ class MDQ extends \SimpleSAML\Metadata\MetaDataStorageSource return $data; } + + /** + * This function loads the metadata for entity IDs in $entityIds. It is returned as an associative array + * where the key is the entity id. An empty array may be returned if no matching entities were found + * @param array $entityIds The entity ids to load + * @param string $set The set we want to get metadata from. + * @return array An associative array with the metadata for the requested entities, if found. + */ + public function getMetaDataForEntities(array $entityIds, $set) + { + return $this->getMetaDataForEntitiesIndividually($entityIds, $set); + } } diff --git a/modules/saml/lib/Auth/Source/SP.php b/modules/saml/lib/Auth/Source/SP.php index b055e8b232c2745e8750eb1de8fd9342f01ba4f9..6aa40efe05b89842d7c7d24377eba47b0d8f1cdb 100644 --- a/modules/saml/lib/Auth/Source/SP.php +++ b/modules/saml/lib/Auth/Source/SP.php @@ -780,10 +780,9 @@ class SP extends \SimpleSAML\Auth\Source if (isset($state['saml:IDPList']) && sizeof($state['saml:IDPList']) > 0) { // we have a SAML IDPList (we are a proxy): filter the list of IdPs available $mdh = MetaDataStorageHandler::getMetadataHandler(); - $known_idps = $mdh->getList(); - $intersection = array_intersect($state['saml:IDPList'], array_keys($known_idps)); + $matchedEntities = $mdh->getMetaDataForEntities($state['saml:IDPList'], 'saml20-idp-remote'); - if (empty($intersection)) { + if (empty($matchedEntities)) { // all requested IdPs are unknown throw new Module\saml\Error\NoSupportedIDP( Constants::STATUS_REQUESTER, @@ -791,7 +790,7 @@ class SP extends \SimpleSAML\Auth\Source ); } - if (!is_null($idp) && !in_array($idp, $intersection, true)) { + if (!is_null($idp) && !array_key_exists($idp, $matchedEntities)) { // the IdP is enforced but not in the IDPList throw new Module\saml\Error\NoAvailableIDP( Constants::STATUS_REQUESTER, @@ -799,9 +798,9 @@ class SP extends \SimpleSAML\Auth\Source ); } - if (is_null($idp) && sizeof($intersection) === 1) { + if (is_null($idp) && sizeof($matchedEntities) === 1) { // only one IdP requested or valid - $idp = current($state['saml:IDPList']); + $idp = key($matchedEntities); } } diff --git a/tests/lib/SimpleSAML/Metadata/MetaDataStorageHandlerTest.php b/tests/lib/SimpleSAML/Metadata/MetaDataStorageHandlerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..d54c5c8e5f4bea95ac18016be535669bc81e4eaa --- /dev/null +++ b/tests/lib/SimpleSAML/Metadata/MetaDataStorageHandlerTest.php @@ -0,0 +1,47 @@ +<?php + +namespace SimpleSAML\Test\Metadata; + +use SimpleSAML\Configuration; +use SimpleSAML\Metadata\MetaDataStorageHandler; +use SimpleSAML\Test\Utils\ClearStateTestCase; + +class MetaDataStorageHandlerTest extends ClearStateTestCase +{ + + /** + * Test that loading specific entities works, and that metadata source precedence is followed + */ + public function testLoadEntities() + { + $c = [ + 'metadata.sources' => [ + ['type' => 'flatfile', 'directory' => __DIR__ . '/test-metadata/source1'], + ['type' => 'serialize', 'directory' => __DIR__ . '/test-metadata/source2'], + ], + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $handler = MetaDataStorageHandler::getMetadataHandler(); + + $entities = $handler->getMetaDataForEntities([ + 'entityA', + 'entityB', + 'nosuchEntity', + 'entityInBoth', + 'expiredInSrc1InSrc2' + ], 'saml20-sp-remote'); + $this->assertCount(4, $entities); + $this->assertEquals('entityA SP from source1', $entities['entityA']['name']['en']); + $this->assertEquals('entityB SP from source2', $entities['entityB']['name']['en']); + $this->assertEquals( + 'entityInBoth SP from source1', + $entities['entityInBoth']['name']['en'], + "Entity is in both sources, but should get loaded from the first" + ); + $this->assertEquals( + 'expiredInSrc1InSrc2 SP from source2', + $entities['expiredInSrc1InSrc2']['name']['en'], + "Entity is in both sources, expired in src1 and available from src2" + ); + } +} diff --git a/tests/lib/SimpleSAML/Metadata/MetaDataStorageSourceTest.php b/tests/lib/SimpleSAML/Metadata/MetaDataStorageSourceTest.php index 6134676c08fb081e86f439bf427407bd47b7f36d..372e6f9440c703d6b5c33886782b76df324a2963 100644 --- a/tests/lib/SimpleSAML/Metadata/MetaDataStorageSourceTest.php +++ b/tests/lib/SimpleSAML/Metadata/MetaDataStorageSourceTest.php @@ -2,6 +2,8 @@ namespace SimpleSAML\Test\Metadata; +use SimpleSAML\Configuration; + /** * Class MetaDataStorageSourceTest */ @@ -36,8 +38,54 @@ class MetaDataStorageSourceTest extends \PHPUnit\Framework\TestCase public function testStaticXMLSource() { $testEntityId = "https://saml.idp/entityid"; + $strTestXML = self::generateIdpMetadataXml($testEntityId); + // The primary test here is that - in contrast to the others above - this loads without error + // As a secondary thing, check that the entity ID from the static source provided can be extracted + $source = \SimpleSAML\Metadata\MetaDataStorageSource::getSource(["type"=>"xml", "xml"=>$strTestXML]); + $idpSet = $source->getMetadataSet("saml20-idp-remote"); + $this->assertArrayHasKey($testEntityId, $idpSet, "Did not extract expected IdP entity ID from static XML source"); + // Finally verify that a different entity ID does not get loaded + $this->assertCount(1, $idpSet, "Unexpectedly got metadata for an alternate entity than that defined"); + } + + /** + * Test loading multiple entities + */ + public function testLoadEntitiesStaticXMLSource() + { + $c = [ + 'key' => 'value' + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $entityId1 = "https://example.com"; + $xml1 = self::generateIdpMetadataXml($entityId1); + $entityId2 = "https://saml.idp/entity"; + $xml2 = self::generateIdpMetadataXml($entityId2); $strTestXML = " -<EntityDescriptor ID=\"_12345678-90ab-cdef-1234-567890abcdef\" entityID=\"$testEntityId\" xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"> + <EntitiesDescriptor xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"> + $xml1 + $xml2 + </EntitiesDescriptor> + "; + $source = \SimpleSAML\Metadata\MetaDataStorageSource::getSource(["type"=>"xml", "xml"=>$strTestXML]); + // search that is a single entity + $entities = $source->getMetaDataForEntities([$entityId2], "saml20-idp-remote"); + $this->assertCount(1, $entities, 'Only 1 entity loaded'); + $this->assertArrayHasKey($entityId2, $entities); + // search for multiple entities + $entities = $source->getMetaDataForEntities([$entityId1, 'no-such-entity', $entityId2], "saml20-idp-remote"); + $this->assertCount(2, $entities, 'Only 2 of the entities are found'); + $this->assertArrayHasKey($entityId1, $entities); + $this->assertArrayHasKey($entityId2, $entities); + // search for non-existant entities + $entities = $source->getMetaDataForEntities(['no-such-entity'], "saml20-idp-remote"); + $this->assertCount(0, $entities, 'no matches expected'); + } + + public static function generateIdpMetadataXml($entityId) + { + return " +<EntityDescriptor ID=\"_12345678-90ab-cdef-1234-567890abcdef\" entityID=\"$entityId\" xmlns=\"urn:oasis:names:tc:SAML:2.0:metadata\"> <RoleDescriptor xsi:type=\"fed:ApplicationServiceType\" protocolSupportEnumeration=\"http://docs.oasis-open.org/ws-sx/ws-trust/200512 http://schemas.xmlsoap.org/ws/2005/02/trust http://docs.oasis-open.org/wsfed/federation/200706\" ServiceDisplayName=\"SimpleSAMLphp Test\" @@ -47,15 +95,10 @@ xmlns:fed=\"http://docs.oasis-open.org/wsfed/federation/200706\"> <SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://saml.idp/sso/\"/> <SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://saml.idp/logout/\"/> </RoleDescriptor> -<IDPSSODescriptor protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\"/> +<IDPSSODescriptor protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\"> +<SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://saml.idp/sso/\"/> +</IDPSSODescriptor> </EntityDescriptor> "; - // The primary test here is that - in contrast to the others above - this loads without error - // As a secondary thing, check that the entity ID from the static source provided can be extracted - $source = \SimpleSAML\Metadata\MetaDataStorageSource::getSource(["type"=>"xml", "xml"=>$strTestXML]); - $idpSet = $source->getMetadataSet("saml20-idp-remote"); - $this->assertArrayHasKey($testEntityId, $idpSet, "Did not extract expected IdP entity ID from static XML source"); - // Finally verify that a different entity ID does not get loaded - $this->assertCount(1, $idpSet, "Unexpectedly got metadata for an alternate entity than that defined"); } } diff --git a/tests/lib/SimpleSAML/Metadata/test-metadata/source1/saml20-sp-remote.php b/tests/lib/SimpleSAML/Metadata/test-metadata/source1/saml20-sp-remote.php new file mode 100644 index 0000000000000000000000000000000000000000..379d1e45c8e344e4b9e29cb515889f71f9056e90 --- /dev/null +++ b/tests/lib/SimpleSAML/Metadata/test-metadata/source1/saml20-sp-remote.php @@ -0,0 +1,60 @@ +<?php + +$metadata['entityA'] = array( + 'entityid' => 'entityA', + 'name' => + array( + 'en' => 'entityA SP from source1', + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => + array( + 0 => + array( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://entityA.example.org/Shibboleth.sso/SAML2/POST', + 'index' => 1, + 'isDefault' => true, + ), + ) +); + +$metadata['entityInBoth'] = array( + 'entityid' => 'entityInBoth', + 'name' => + array( + 'en' => 'entityInBoth SP from source1', + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => + array( + 0 => + array( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://entityInBoth.example.org/Shibboleth.sso/SAML2/POST', + 'index' => 1, + 'isDefault' => true, + ), + ) +); + +$metadata['expiredInSrc1InSrc2'] = array( + 'entityid' => 'expiredInSrc1InSrc2', + // This entity is expired in src1 but unexpired in src2 + 'expire' => 1, + 'name' => + array( + 'en' => 'expiredInSrc1InSrc2 SP from source1', + ), + 'metadata-set' => 'saml20-sp-remote', + 'AssertionConsumerService' => + array( + 0 => + array( + 'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + 'Location' => 'https://expiredInSrc1InSrc2.example.org/Shibboleth.sso/SAML2/POST', + 'index' => 1, + 'isDefault' => true, + ), + ) +); \ No newline at end of file diff --git a/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityB.serialized b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityB.serialized new file mode 100644 index 0000000000000000000000000000000000000000..996e4a1f8ac73c0cc9e58678573aadb6bcfc6b3d --- /dev/null +++ b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityB.serialized @@ -0,0 +1 @@ +a:4:{s:8:"entityid";s:7:"entityB";s:4:"name";a:1:{s:2:"en";s:23:"entityB SP from source2";}s:12:"metadata-set";s:16:"saml20-sp-remote";s:24:"AssertionConsumerService";a:1:{i:0;a:4:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:53:"https://entityB.example.org/Shibboleth.sso/SAML2/POST";s:5:"index";i:1;s:9:"isDefault";b:1;}}} \ No newline at end of file diff --git a/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityInBoth.serialized b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityInBoth.serialized new file mode 100644 index 0000000000000000000000000000000000000000..fe0b1d3514a808ebc318c64d13559f0470f8bc73 --- /dev/null +++ b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/entityInBoth.serialized @@ -0,0 +1 @@ +a:4:{s:8:"entityid";s:12:"entityInBoth";s:4:"name";a:1:{s:2:"en";s:28:"entityInBoth SP from source2";}s:12:"metadata-set";s:16:"saml20-sp-remote";s:24:"AssertionConsumerService";a:1:{i:0;a:4:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:58:"https://entityInBoth.example.org/Shibboleth.sso/SAML2/POST";s:5:"index";i:1;s:9:"isDefault";b:1;}}} \ No newline at end of file diff --git a/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/expiredInSrc1InSrc2.serialized b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/expiredInSrc1InSrc2.serialized new file mode 100644 index 0000000000000000000000000000000000000000..4c137936904f5c695f834fe85fe4aeb36c303352 --- /dev/null +++ b/tests/lib/SimpleSAML/Metadata/test-metadata/source2/saml20-sp-remote/expiredInSrc1InSrc2.serialized @@ -0,0 +1 @@ +a:5:{s:8:"entityid";s:19:"expiredInSrc1InSrc2";s:6:"expire";i:3659688740;s:4:"name";a:1:{s:2:"en";s:35:"expiredInSrc1InSrc2 SP from source2";}s:12:"metadata-set";s:16:"saml20-sp-remote";s:24:"AssertionConsumerService";a:1:{i:0;a:4:{s:7:"Binding";s:46:"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";s:8:"Location";s:65:"https://expiredInSrc1InSrc2.example.org/Shibboleth.sso/SAML2/POST";s:5:"index";i:1;s:9:"isDefault";b:1;}}} diff --git a/tests/modules/saml/lib/Auth/Source/Auth_Source_SP_Test.php b/tests/modules/saml/lib/Auth/Source/Auth_Source_SP_Test.php index 53da3c2e9822342be8c4173b57e316db126a62fa..6ead560d7711b9c9aceb95bb81df21c4a5186e47 100644 --- a/tests/modules/saml/lib/Auth/Source/Auth_Source_SP_Test.php +++ b/tests/modules/saml/lib/Auth/Source/Auth_Source_SP_Test.php @@ -2,8 +2,14 @@ namespace SimpleSAML\Test\Module\saml\Auth\Source; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use SAML2\AuthnRequest; use \SimpleSAML\Configuration; +use SimpleSAML\Module\saml\Error\NoAvailableIDP; +use SimpleSAML\Module\saml\Error\NoSupportedIDP; +use SimpleSAML\Test\Metadata\MetaDataStorageSourceTest; +use SimpleSAML\Test\Utils\ClearStateTestCase; /** * Custom Exception to throw to terminate a TestCase. @@ -68,7 +74,7 @@ class SPTester extends \SimpleSAML\Module\saml\Auth\Source\SP /** * Set of test cases for \SimpleSAML\Module\saml\Auth\Source\SP. */ -class SPTest extends TestCase +class SPTest extends ClearStateTestCase { private $idpMetadata = null; @@ -91,6 +97,7 @@ class SPTest extends TestCase protected function setUp() { + parent::setUp(); $this->idpConfigArray = [ 'metadata-set' => 'saml20-idp-remote', 'entityid' => 'https://engine.surfconext.nl/authentication/idp/metadata', @@ -268,4 +275,154 @@ class SPTest extends TestCase $q[0]->value ); } + + /** + * Test specifying an IDPList where no metadata found for those idps is an error + */ + public function testIdpListWithNoMatchingMetadata() + { + $this->expectException(NoSupportedIDP::class); + $state = [ + 'saml:IDPList' => ['noSuchIdp'] + ]; + + $info = ['AuthId' => 'default-sp']; + $config = []; + $as = new SPTester($info, $config); + $as->authenticate($state); + } + + /** + * Test specifying an IDPList where the list does not overlap with the Idp specified in SP config is an error + */ + public function testIdpListWithExplicitIdpNotMatch() + { + $this->expectException(NoAvailableIDP::class); + $entityId = "https://example.com"; + $xml = MetaDataStorageSourceTest::generateIdpMetadataXml($entityId); + $c = [ + 'metadata.sources' => [ + ["type"=>"xml", "xml"=>$xml], + ], + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $state = [ + 'saml:IDPList' => ['noSuchIdp', $entityId] + ]; + + $info = ['AuthId' => 'default-sp']; + $config = [ + 'idp' => 'https://engine.surfconext.nl/authentication/idp/metadata' + ]; + $as = new SPTester($info, $config); + $as->authenticate($state); + } + + /** + * Test that IDPList overlaps with the IDP specified in SP config results in AuthnRequest + */ + public function testIdpListWithExplicitIdpMatch() + { + $entityId = "https://example.com"; + $xml = MetaDataStorageSourceTest::generateIdpMetadataXml($entityId); + $c = [ + 'metadata.sources' => [ + ["type"=>"xml", "xml"=>$xml], + ], + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $state = [ + 'saml:IDPList' => ['noSuchIdp', $entityId] + ]; + + $info = ['AuthId' => 'default-sp']; + $config = [ + 'idp' => $entityId + ]; + $as = new SPTester($info, $config); + try { + $as->authenticate($state); + $this->fail('Expected ExitTestException'); + } catch (ExitTestException $e) { + $r = $e->getTestResult(); + /** @var AuthnRequest $ar */ + $ar = $r['ar']; + $xml = $ar->toSignedXML(); + $q = \SAML2\Utils::xpQuery($xml, '/samlp:AuthnRequest/@Destination'); + $this->assertEquals( + 'https://saml.idp/sso/', + $q[0]->value + ); + } + } + + /** + * Test that IDPList with a single valid idp and no SP config idp results in AuthnRequest to that idp + */ + public function testIdpListWithSingleMatch() + { + $entityId = "https://example.com"; + $xml = MetaDataStorageSourceTest::generateIdpMetadataXml($entityId); + $c = [ + 'metadata.sources' => [ + ["type"=>"xml", "xml"=>$xml], + ], + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $state = [ + 'saml:IDPList' => ['noSuchIdp', $entityId] + ]; + + $info = ['AuthId' => 'default-sp']; + $config = []; + $as = new SPTester($info, $config); + try { + $as->authenticate($state); + $this->fail('Expected ExitTestException'); + } catch (ExitTestException $e) { + $r = $e->getTestResult(); + /** @var AuthnRequest $ar */ + $ar = $r['ar']; + $xml = $ar->toSignedXML(); + $q = \SAML2\Utils::xpQuery($xml, '/samlp:AuthnRequest/@Destination'); + $this->assertEquals( + 'https://saml.idp/sso/', + $q[0]->value + ); + } + } + + /** + * Test that IDPList with multiple valid idp and no SP config idp results in discovery redirect + */ + public function testIdpListWithMultipleMatch() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid URL: smtp://invalidurl'); + $entityId = "https://example.com"; + $xml = MetaDataStorageSourceTest::generateIdpMetadataXml($entityId); + $entityId1 = "https://example1.com"; + $xml1 = MetaDataStorageSourceTest::generateIdpMetadataXml($entityId1); + $c = [ + 'metadata.sources' => [ + ["type"=>"xml", "xml"=>$xml], + ["type"=>"xml", "xml"=>$xml1], + ], + ]; + Configuration::loadFromArray($c, '', 'simplesaml'); + $state = [ + 'saml:IDPList' => ['noSuchIdp', $entityId, $entityId1] + ]; + + $info = ['AuthId' => 'default-sp']; + $config = [ + // Use a url that is invalid for http redirects so redirect code throws an error + // otherwise it will call exit + 'discoURL' => 'smtp://invalidurl' + ]; + // Http redirect util library requires a request_uri to be set. + $_SERVER['REQUEST_URI'] = 'https://l.example.com/'; + $as = new SPTester($info, $config); + $as->authenticate($state); + } }