diff --git a/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php b/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php index e171ab90bfd6f205a3a9994db1ef04fac2eeda28..f39c2c0478aa20531702b53795bc3293d855b317 100644 --- a/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php +++ b/lib/SimpleSAML/Metadata/MetaDataStorageHandler.php @@ -2,6 +2,8 @@ namespace SimpleSAML\Metadata; +use SimpleSAML\Utils\ClearableState; + /** * This file defines a class for metadata handling. * @@ -9,7 +11,7 @@ namespace SimpleSAML\Metadata; * @package SimpleSAMLphp */ -class MetaDataStorageHandler +class MetaDataStorageHandler implements ClearableState { /** * This static variable contains a reference to the current @@ -358,4 +360,14 @@ class MetaDataStorageHandler return null; } + + /** + * Clear any metadata cached. + * Allows for metadata configuration to be changed and reloaded during a given request. Most useful + * when running phpunit tests and needing to alter config.php and metadata sources between test cases + */ + public static function clearInternalState() + { + self::$metadataHandler = null; + } } diff --git a/modules/saml/lib/IdP/SAML2.php b/modules/saml/lib/IdP/SAML2.php index 4118b56b7dea87f673a44a04ade198e94ee990d5..764e0d1c3881aeb064513aa537fdb9cb40e2a042 100644 --- a/modules/saml/lib/IdP/SAML2.php +++ b/modules/saml/lib/IdP/SAML2.php @@ -265,7 +265,7 @@ class SAML2 $supportedBindings[] = \SAML2\Constants::BINDING_PAOS; } - if (isset($_REQUEST['spentityid'])) { + if (isset($_REQUEST['spentityid']) || isset($_REQUEST['providerId'])) { /* IdP initiated authentication. */ if (isset($_REQUEST['cookieTime'])) { @@ -279,11 +279,13 @@ class SAML2 } } - $spEntityId = (string) $_REQUEST['spentityid']; + $spEntityId = (string) isset($_REQUEST['spentityid']) ? $_REQUEST['spentityid'] : $_REQUEST['providerId']; $spMetadata = $metadata->getMetaDataConfig($spEntityId, 'saml20-sp-remote'); if (isset($_REQUEST['RelayState'])) { $relayState = (string) $_REQUEST['RelayState']; + } elseif (isset($_REQUEST['target'])) { + $relayState = (string) $_REQUEST['target']; } else { $relayState = null; } @@ -300,13 +302,20 @@ class SAML2 $nameIDFormat = null; } + if (isset($_REQUEST['ConsumerURL'])) { + $consumerURL = (string)$_REQUEST['ConsumerURL']; + } elseif (isset($_REQUEST['shire'])) { + $consumerURL = (string)$_REQUEST['shire']; + } else { + $consumerURL = null; + } + $requestId = null; $IDPList = []; $ProxyCount = null; $RequesterID = null; $forceAuthn = false; $isPassive = false; - $consumerURL = null; $consumerIndex = null; $extensions = null; $allowCreate = true; @@ -402,11 +411,15 @@ class SAML2 $sessionLostParams = [ 'spentityid' => $spEntityId, - 'cookieTime' => time(), ]; if ($relayState !== null) { $sessionLostParams['RelayState'] = $relayState; } + /* + Putting cookieTime as the last parameter makes unit testing easier since we don't need to handle a + changing time component in the middle of the url + */ + $sessionLostParams['cookieTime'] = time(); $sessionLostURL = \SimpleSAML\Utils\HTTP::addURLParameters( \SimpleSAML\Utils\HTTP::getSelfURLNoQuery(), diff --git a/tests/Utils/StateClearer.php b/tests/Utils/StateClearer.php index ee2b686bf58af1f5a002e324efcc402833c64443..880c66bdf6332969503f23ec23743d7d0e6868b7 100644 --- a/tests/Utils/StateClearer.php +++ b/tests/Utils/StateClearer.php @@ -18,7 +18,7 @@ class StateClearer * Class that implement \SimpleSAML\Utils\ClearableState and should have clearInternalState called between tests * @var array */ - private $clearableState = ['SimpleSAML\Configuration']; + private $clearableState = ['SimpleSAML\Configuration', 'SimpleSAML\Metadata\MetaDataStorageHandler']; /** * Environmental variables to unset diff --git a/tests/modules/saml/lib/IdP/SAML2Test.php b/tests/modules/saml/lib/IdP/SAML2Test.php index c6701cbd34cf550c513625050934c16ddba176d5..ef94baf9a28b7bf2510cfba38ba5d95d1820dcce 100644 --- a/tests/modules/saml/lib/IdP/SAML2Test.php +++ b/tests/modules/saml/lib/IdP/SAML2Test.php @@ -2,7 +2,12 @@ namespace SimpleSAML\Test\Module\saml\IdP; -class SAML2Test extends \PHPUnit_Framework_TestCase +use SimpleSAML\Configuration; +use SimpleSAML\IdP; +use SimpleSAML\Module\saml\IdP\SAML2; +use SimpleSAML\Test\Utils\ClearStateTestCase; + +class SAML2Test extends ClearStateTestCase { public function testProcessSOAPAuthnRequest() { @@ -23,6 +28,9 @@ class SAML2Test extends \PHPUnit_Framework_TestCase $_SERVER['PHP_AUTH_PW'] = 'password'; unset($_SERVER['PHP_AUTH_USER']); $state = []; + Configuration::loadFromArray([ + 'baseurlpath' => 'https://idp.example.com/', + ], '', 'simplesaml'); \SimpleSAML\Module\saml\IdP\SAML2::processSOAPAuthnRequest($state); } @@ -37,4 +45,195 @@ class SAML2Test extends \PHPUnit_Framework_TestCase \SimpleSAML\Module\saml\IdP\SAML2::processSOAPAuthnRequest($state); } + + /** + * Default values for the state array expected to be generated at the start of logins + * @var array + */ + private $defaultExpectedAuthState = [ + 'Responder' =>['\SimpleSAML\Module\saml\IdP\SAML2', 'sendResponse'], + '\SimpleSAML\Auth\State.exceptionFunc' => ['\SimpleSAML\Module\saml\IdP\SAML2', 'handleAuthError'], + 'saml:RelayState' => null, + 'saml:RequestId' => null, + 'saml:IDPList' => [], + 'saml:ProxyCount' => null, + 'saml:RequesterID' => null, + 'ForceAuthn' => false, + 'isPassive' => false, + 'saml:ConsumerURL' => 'SP-specific', + 'saml:Binding' => 'SP-specific', + 'saml:NameIDFormat' => null, + 'saml:AllowCreate' => true, + 'saml:Extensions' => null, + 'saml:RequestedAuthnContext' => null]; + + /** + * Test that invoking the idp initiated endpoint with the minimum necessary parameters works. + */ + public function testIdPInitiatedLoginMinimumParams() + { + $state = $this->idpInitiatedHelper(['spentityid' => 'https://some-sp-entity-id']); + $this->assertEquals('https://some-sp-entity-id', $state['SPMetadata']['entityid']); + + $this->assertStringStartsWith( + 'http://idp.examlple.com/saml2/idp/SSOService.php?spentityid=https%3A%2F%2Fsome-sp-entity-id&cookie', + $state['\SimpleSAML\Auth\State.restartURL'] + ); + unset($state['saml:AuthnRequestReceivedAt']); // timestamp can't be tested in equality assertion + unset($state['SPMetadata']); // entityid asserted above + unset($state['\SimpleSAML\Auth\State.restartURL']); // url contains a cookie time which varies by test + + $expectedState = $this->defaultExpectedAuthState; + $expectedState[ 'saml:ConsumerURL'] = 'https://example.com/Shibboleth.sso/SAML2/POST'; + $expectedState[ 'saml:Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'; + + $this->assertEquals($expectedState, $state); + } + + /** + * Test that invoking the idp initiated endpoint with the optional parameters works. + */ + public function testIdPInitiatedLoginOptionalParams() + { + $state = $this->idpInitiatedHelper([ + 'spentityid' => 'https://some-sp-entity-id', + 'RelayState' => 'http://relay', + 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:PAOS', + 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + + ]); + $this->assertEquals('https://some-sp-entity-id', $state['SPMetadata']['entityid']); + + //currently only spentityid and relay state are used in the restart url. + $this->assertStringStartsWith( + 'http://idp.examlple.com/saml2/idp/SSOService.php?' + . 'spentityid=https%3A%2F%2Fsome-sp-entity-id&RelayState=http%3A%2F%2Frelay&cookieTime', + $state['\SimpleSAML\Auth\State.restartURL'] + ); + unset($state['saml:AuthnRequestReceivedAt']); // timestamp can't be tested in equality assertion + unset($state['SPMetadata']); // entityid asserted above + unset($state['\SimpleSAML\Auth\State.restartURL']); // url contains a cookie time which varies by test + + $expectedState = $this->defaultExpectedAuthState; + $expectedState['saml:ConsumerURL'] = 'https://example.com/Shibboleth.sso/SAML2/ECP'; + $expectedState['saml:Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:PAOS'; + $expectedState['saml:NameIDFormat'] = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'; + $expectedState['saml:RelayState'] = 'http://relay'; + + $this->assertEquals($expectedState, $state); + } + + /** + * Test that invoking the idp initiated endpoint using minimum shib params works + */ + public function testIdPInitShibCompatyMinimumParams() + { + //https://wiki.shibboleth.net/confluence/display/IDP30/UnsolicitedSSOConfiguration + // Shib uses the param providerId instead of spentityid + $state = $this->idpInitiatedHelper(['providerId' => 'https://some-sp-entity-id']); + $this->assertEquals('https://some-sp-entity-id', $state['SPMetadata']['entityid']); + + $this->assertStringStartsWith( + 'http://idp.examlple.com/saml2/idp/SSOService.php?spentityid=https%3A%2F%2Fsome-sp-entity-id&cookie', + $state['\SimpleSAML\Auth\State.restartURL'] + ); + unset($state['saml:AuthnRequestReceivedAt']); // timestamp can't be tested in equality assertion + unset($state['SPMetadata']); // entityid asserted above + unset($state['\SimpleSAML\Auth\State.restartURL']); // url contains a cookie time which varies by test + + $expectedState = $this->defaultExpectedAuthState; + $expectedState[ 'saml:ConsumerURL'] = 'https://example.com/Shibboleth.sso/SAML2/POST'; + $expectedState[ 'saml:Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'; + + $this->assertEquals($expectedState, $state); + } + + /** + * Test that invoking the idp initiated endpoint using minimum shib params works + */ + public function testIdPInitShibCompatOptionalParams() + { + $state = $this->idpInitiatedHelper([ + 'providerId' => 'https://some-sp-entity-id', + 'target' => 'http://relay', + 'shire' => 'https://example.com/Shibboleth.sso/SAML2/ECP', + ]); + $this->assertEquals('https://some-sp-entity-id', $state['SPMetadata']['entityid']); + + //currently only spentityid and relay state are used in the restart url. + $this->assertStringStartsWith( + 'http://idp.examlple.com/saml2/idp/SSOService.php?' + . 'spentityid=https%3A%2F%2Fsome-sp-entity-id&RelayState=http%3A%2F%2Frelay&cookieTime', + $state['\SimpleSAML\Auth\State.restartURL'] + ); + unset($state['saml:AuthnRequestReceivedAt']); // timestamp can't be tested in equality assertion + unset($state['SPMetadata']); // entityid asserted above + unset($state['\SimpleSAML\Auth\State.restartURL']); // url contains a cookie time which varies by test + + $expectedState = $this->defaultExpectedAuthState; + $expectedState['saml:ConsumerURL'] = 'https://example.com/Shibboleth.sso/SAML2/ECP'; + $expectedState['saml:Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:PAOS'; + $expectedState['saml:RelayState'] = 'http://relay'; + + $this->assertEquals($expectedState, $state); + } + + /** + * Invoke IDP initiated login with the given query parameters. + * Callers should validate the return state array or confirm appropriate exceptions are returned. + * + * @param array $queryParams + * @return string[] The state array used for handling the authentication request. + */ + private function idpInitiatedHelper(array $queryParams) + { + /** @var $idpStub \PHPUnit_Framework_MockObject_MockObject|IdP */ + $idpStub = $this->getMockBuilder(IdP::class) + ->disableOriginalConstructor() + ->getMock(); + $idpMetadata = Configuration::loadFromArray([ + 'entityid' => 'https://idp-entity.id', + 'saml20.ecp' => true, //enable additional bindings so we can test selection logic + ]); + + $idpStub->method("getConfig") + ->willReturn($idpMetadata); + + // phpcs:disable + $spMetadataXml = <<< 'EOT' +<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://some-sp-entity-id"> + <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol urn:oasis:names:tc:SAML:1.1:protocol"> + <md:AssertionConsumerService index="1" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.com/Shibboleth.sso/SAML2/POST" /> + <md:AssertionConsumerService index="2" Binding="urn:oasis:names:tc:SAML:2.0:bindings:PAOS" Location="https://example.com/Shibboleth.sso/SAML2/ECP" /> + </md:SPSSODescriptor> +</EntityDescriptor> +EOT; + // phpcs:enable + + Configuration::loadFromArray([ + 'baseurlpath' => 'https://idp.example.com/', + 'metadata.sources' => [ + ["type" => "xml", 'xml' => $spMetadataXml], + ], + ], '', 'simplesaml'); + + // Since we aren't really running on a webserver some of the url calculations done, such as for restart url + // won't line up perfectly + $_REQUEST = $_REQUEST + $queryParams; + $_SERVER['HTTP_HOST'] = 'idp.examlple.com'; + $_SERVER['REQUEST_URI'] = '/saml2/idp/SSOService.php?' . http_build_query($queryParams); + + + $state = []; + $idpStub->expects($this->once()) + ->method('handleAuthenticationRequest') + ->with($this->callback(function ($arg) use (&$state) { + $state = $arg; + return true; + })); + + SAML2::receiveAuthnRequest($idpStub); + + return $state; + } }