diff --git a/lib/SimpleSAML/Metadata/SAMLParser.php b/lib/SimpleSAML/Metadata/SAMLParser.php new file mode 100644 index 0000000000000000000000000000000000000000..66abbdccdef7988b7f6275e974fb168c70433123 --- /dev/null +++ b/lib/SimpleSAML/Metadata/SAMLParser.php @@ -0,0 +1,534 @@ +<?php + +require_once('SimpleSAML/Utilities.php'); + +/** + * This is class for parsing of SAML 1.x and SAML 2.0 metadata. + * + * Metadata is loaded by calling SimpleSAML_Metadata_SAMLParser::parseFile or + * SimpleSAML_Metadata_SAMLParser::parseString. These functions returns an instance of + * SimpleSAML_Metadata_SAMLParser. To get metadata from this object, use the methods + * getMetadata1xSP or getMetadata20SP. + */ +class SimpleSAML_Metadata_SAMLParser { + + /** + * This is the list of SAML 1.x protocols. + */ + private static $SAML1xProtocols = array( + 'urn:oasis:names:tc:SAML:1.0:protocol', + 'urn:oasis:names:tc:SAML:1.1:protocol', + ); + + + /** + * This is the list with the SAML 2.0 protocol. + */ + private static $SAML20Protocols = array( + 'urn:oasis:names:tc:SAML:2.0:protocol', + ); + + + /** + * This is the binding used for browser post in SAML 1.x. + */ + const SAML_1X_POST_BINDING = 'urn:oasis:names:tc:SAML:1.0:profiles:browser-post'; + + + /** + * This is the binding used for HTTP-POST in SAML 2.0. + */ + const SAML_20_POST_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'; + + + /** + * This is the binding used for HTTP-REDIRECT in SAML 2.0. + */ + const SAML_20_REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'; + + + /** + * This is the entity id we find in the metadata. + */ + private $entityId; + + + /** + * This is an array with the processed SPSSODescriptor elements we have found in this + * metadata file. + * Each element in the array is an associative array with the following elements: + * - 'protocols': Array with the protocols this SPSSODescriptor supports. + * - 'assertionConsumerServices': Array with the SP's assertion consumer services. + * Each assertion consumer service is stored as an associative array with the + * elements that parseGenericEndpoint returns. + * - 'singleLogoutServices': Array with the SP's single logout service endpoints. Each endpoint is stored + * as an associative array with the elements that parseGenericEndpoint returns. + * - 'nameIDFormats': The NameIDFormats that the SP accepts. This may be an empty array. + */ + private $spDescriptors; + + + + /** + * This is the constructor for the SAMLParser class. + * + * @param $doc The DOMDocument with the metadata. + */ + private function __construct($doc) { + $this->spDescriptors = array(); + + $this->processDOMDocument($doc); + } + + + /** + * This function parses a file which contains XML encoded metadata. + * + * @param $file The path to the file which contains the metadata. + * @return An instance of this class with the metadata loaded. + */ + public static function parseFile($file) { + $doc = new DOMDocument(); + + $res = $doc->load($file); + if($res !== TRUE) { + throw new Exception('Failed to read XML from file: ' . $file); + } + + return new SimpleSAML_Metadata_SAMLParser($doc); + } + + + /** + * This function parses a string which contains XML encoded metadata. + * + * @param $metadata A string which contains XML encoded metadata. + * @return An instance of this class with the metadata loaded. + */ + public static function parseString($metadata) { + $doc = new DOMDocument(); + + $res = $doc->loadXML($metadata); + if($res !== TRUE) { + throw new Exception('Failed to parse XML string.'); + } + + return new SimpleSAML_Metadata_SAMLParser($doc); + } + + + /** + * This function returns the metadata for SAML 1.x SPs in the format simpleSAMLphp expects. + * This is an associative array with the following fields: + * - 'entityID': The entity id of the entity described in the metadata. + * - 'AssertionConsumerService': String with the url of the assertion consumer service which supports + * the browser-post binding. + * + * Metadata must be loaded with one of the parse functions before this function can be called. + * + * @return Associative array with metadata or NULL if we are unable to generate metadata for a SAML 1.x SP. + */ + public function getMetadata1xSP() { + assert('$this->metadataLoaded == TRUE'); + + $ret = array(); + + $ret['entityID'] = $this->entityID; + + + /* Find SP information which supports one of the SAML 1.x protocols. */ + $spd = $this->getSPDescriptors(self::$SAML1xProtocols); + if(count($spd) === 0) { + return NULL; + } + + /* We currently only look at the first SPDescriptor which supports SAML 1.x. */ + $spd = $spd[0]; + + /* Find the assertion consumer service endpoint. */ + $acs = $this->getDefaultEndpoint($spd['assertionConsumerServices'], array(self::SAML_1X_POST_BINDING)); + if($acs === NULL) { + throw new Exception('Could not find any valid AssertionConsumerService.' . + ' simpleSAMLphp currently supports only the browser-post binding for SAML 1.x.'); + } + + $ret['AssertionConsumerService'] = $acs['location']; + + + return $ret; + } + + + /** + * This function returns the metadata for SAML 2.0 SPs in the format simpleSAMLphp expects. + * This is an associative array with the following fields: + * - 'entityID': The entity id of the entity described in the metadata. + * - 'AssertionConsumerService': String with the url of the assertion consumer service which supports + * the browser-post binding. + * - 'SingleLogoutService': String with the url where we should send logout requests/responses. + * - 'NameIDFormat': The name ID format this SP expects. This may be unset. + * + * Metadata must be loaded with one of the parse functions before this function can be called. + * + * @return Associative array with metadata or NULL if we are unable to generate metadata for a SAML 1.x SP. + */ + public function getMetadata20SP() { + assert('$this->metadataLoaded == TRUE'); + + $ret = array(); + + $ret['entityID'] = $this->entityID; + + + /* Find SP information which supports the SAML 2.0 protocol. */ + $spd = $this->getSPDescriptors(self::$SAML20Protocols); + if(count($spd) === 0) { + return NULL; + } + + /* We currently only look at the first SPDescriptor which supports SAML 2.0. */ + $spd = $spd[0]; + + /* Find the assertion consumer service endpoint. */ + $acs = $this->getDefaultEndpoint($spd['assertionConsumerServices'], array(self::SAML_20_POST_BINDING)); + if($acs === NULL) { + throw new Exception('Could not find any valid AssertionConsumerService.' . + ' simpleSAMLphp currently supports only the http-post binding for SAML 2.0 assertions.'); + } + + $ret['AssertionConsumerService'] = $acs['location']; + + + /* Find the single logout service endpoint. */ + $acs = $this->getDefaultEndpoint($spd['singleLogoutServices'], array(self::SAML_20_REDIRECT_BINDING)); + if($acs === NULL) { + throw new Exception('Could not find any valid SingleLogoutService.' . + ' simpleSAMLphp currently supports only the http-redirect binding for SAML 1.0 logout.'); + } + + $ret['SingleLogoutService'] = $acs['location']; + + + /* Find the NameIDFormat. This may not exists. */ + if(count($spd['nameIDFormats']) > 0) { + /* simpleSAMLphp currently only supports a single NameIDFormat pr. SP. We use the first one. */ + $ret['NameIDFormat'] = $spd['nameIDFormats'][0]; + } + + return $ret; + } + + + /** + * This function parses a DOMDocument which contains a single EntityDescriptor element. + * + * @param $doc The DOMDocument which should be parsed. + */ + private function processDOMDocument($doc) { + + $ed = self::findEntityDescriptor($doc); + + /* Extract the entityID from the EntityDescriptor element. This is a required + * attribute, so we throw an exception if it isn't found. + */ + if(!$ed->hasAttribute('entityID')) { + throw new Exception('EntityDescriptor missing required entityID attribute.'); + } + $this->entityID = $ed->getAttribute('entityID'); + + + /* Look over the child nodes for any known element types. */ + for($i = 0; $i < $ed->childNodes->length; $i++) { + $child = $ed->childNodes->item($i); + + /* Skip text nodes. */ + if($child instanceof DOMText) { + continue; + } + + if(SimpleSAML_Utilities::isDOMElementOfType($child, 'SPSSODescriptor', '@md') === TRUE) { + $this->processSPSSODescriptor($child); + } + } + + $this->metadataLoaded = TRUE; + } + + + /** + * This function extracts metadata from a SPSSODescriptor element. + * + * @param $element The element which should be parsed. + */ + private function processSPSSODescriptor($element) { + assert('$element instanceof DOMElement'); + + $sp = array(); + + $sp['protocols'] = self::getSupportedProtocols($element); + + /* Find all AssertionConsumerService elements. */ + $sp['assertionConsumerServices'] = array(); + $acs = SimpleSAML_Utilities::getDOMChildren($element, 'AssertionConsumerService', '@md'); + foreach($acs as $child) { + $sp['assertionConsumerServices'][] = self::parseAssertionConsumerService($child); + } + + /* Find all SingleLogoutService elements. */ + $sp['singleLogoutServices'] = array(); + $sls = SimpleSAML_Utilities::getDOMChildren($element, 'SingleLogoutService', '@md'); + foreach($sls as $child) { + $sp['singleLogoutServices'][] = self::parseSingleLogoutService($child); + } + + /* Process NameIDFormat elements. */ + $sp['nameIDFormats'] = array(); + $nif = SimpleSAML_Utilities::getDOMChildren($element, 'NameIDFormat', '@md'); + if(count($nif) > 0) { + $sp['nameIDFormats'][] = self::parseNameIDFormat($nif[0]); + } + + + $this->spDescriptors[] = $sp; + } + + + /** + * This function parses AssertionConsumerService elements. + * + * @param $element The element which should be parsed. + * @return Associative array with the data we have extracted from the AssertionConsumerService element. + */ + private static function parseAssertionConsumerService($element) { + assert('$element instanceof DOMElement'); + + return self::parseGenericEndpoint($element, TRUE); + } + + + /** + * This function parses SingleLogoutService elements. + * + * @param $element The element which should be parsed. + * @return Associative array with the data we have extracted from the SingleLogoutService element. + */ + private static function parseSingleLogoutService($element) { + assert('$element instanceof DOMElement'); + + return self::parseGenericEndpoint($element, FALSE); + } + + + /** + * This function parses NameIDFormat elements. + * + * @param $element The element which should be parsed. + * @return URN with the supported NameIDFormat. + */ + private static function parseNameIDFormat($element) { + assert('$element instanceof DOMElement'); + + $fmt = ''; + + for($i = 0; $i < $element->childNodes->length; $i++) { + $child = $element->childNodes->item($i); + if(!($child instanceof DOMText)) { + throw new Exception('NameIDFormat contained a non-text child node.'); + } + + $fmt .= $child->wholeText; + } + + $fmt = trim($fmt); + return $fmt; + } + + + /** + * This function is a generic endpoint element parser. + * + * The returned associative array has the following elements: + * - 'binding': The binding this endpoint uses. + * - 'location': The URL to this endpoint. + * - 'responseLocation': The URL where responses should be sent. This may not exist. + * - 'index': The index of this endpoint. This attribute is only for indexed endpoints. + * - 'isDefault': Whether this endpoint is the default endpoint for this type. This attribute may not exist. + * + * @param $element The element which should be parsed. + * @param $isIndexed Wheter the endpoint is an indexed endpoint (and may have the index and isDefault attributes.). + * @return Associative array with the data we have extracted from the element. + */ + private static function parseGenericEndpoint($element, $isIndexed) { + assert('$element instanceof DOMElement'); + assert('is_bool($isIndexed)'); + + $name = $element->localName; + + $ep = array(); + + if(!$element->hasAttribute('Binding')) { + throw new Exception($name . ' missing required Binding attribute.'); + } + $ep['binding'] = $element->getAttribute('Binding'); + + if(!$element->hasAttribute('Location')) { + throw new Exception($name . ' missing required Location attribute.'); + } + $ep['location'] = $element->getAttribute('Location'); + + if($element->hasAttribute('ResponseLocation')) { + $ep['responseLocation'] = $element->getAttribute('Location'); + } + + if($isIndexed) { + if(!$element->hasAttribute('index')) { + throw new Exception($name . ' missing required index attribute.'); + } + $ep['index'] = $element->getAttribute('index'); + + if($element->hasAttribute('isDefault')) { + $t = $element->getAttribute('isDefault'); + if($t === 'false') { + $ep['isDefault'] = FALSE; + } elseif($t === 'true') { + $ep['isDefault'] = TRUE; + } else { + throw new Exception('Invalid value for isDefault attribute on ' . + $name . ' element: ' . $t); + } + } + } + + return $ep; + } + + + /** + * This function attempts to locate the default endpoint which supports one of the given bindings. + * + * @param $endpoints Array with endpoints in the format returned by parseGenericEndpoint. + * @param $acceptedBindings Array with the accepted bindings. If this is NULL, then we accept any binding. + * @return The default endpoint which supports one of the bindings, or NULL if no endpoints supports + * one of the bindings. + */ + private function getDefaultEndpoint($endpoints, $acceptedBindings = NULL) { + + assert('$acceptedBindings === NULL || is_array($acceptedBindings)'); + + /* Filter the list of endpoints if $acceptedBindings !== NULL. */ + if($acceptedBindings !== NULL) { + $newEndpoints = array(); + + foreach($endpoints as $ep) { + /* Add it to the list of valid ACSs if it has one of the supported bindings. */ + if(in_array($ep['binding'], $acceptedBindings, TRUE)) { + $newEndpoints[] = $ep; + } + } + + $endpoints = $newEndpoints; + } + + + /* First we look for the endpoint with isDefault set to true. */ + foreach($endpoints as $ep) { + + if(array_key_exists('isDefault', $ep) && $ep['isDefault'] === TRUE) { + return $ep; + } + } + + /* Then we look for the first endpoint without isDefault set to FALSE. */ + foreach($endpoints as $ep) { + + if(!array_key_exists('isDefault', $ep)) { + return $ep; + } + } + + /* Then we take the first endpoint we find. */ + if(count($endpoints) > 0) { + return $endpoints[0]; + } + + /* If we reach this point, then we don't have any endpoints with the correct binding. */ + return NULL; + } + + + /** + * This function finds SP descriptors which supports one of the given protocols. + * + * @param $protocols Array with the protocols we accept. + * @return Array with SP descriptors which supports one of the given protocols. + */ + private function getSPDescriptors($protocols) { + assert('is_array($protocols)'); + + $ret = array(); + + foreach($this->spDescriptors as $spd) { + $sharedProtocols = array_intersect($protocols, $spd['protocols']); + if(count($sharedProtocols) > 0) { + $ret[] = $spd; + } + } + + return $ret; + } + + + /** + * This function locates the EntityDescriptor node in a DOMDocument. This node should + * be the first (and only) node in the document. + * + * This function will throw an exception if it is unable to locate the node. + * + * @param $doc The DOMDocument where we should find the EntityDescriptor node. + * @return The DOMEntity which represents the EntityDescriptor. + */ + private static function findEntityDescriptor($doc) { + + assert('$doc instanceof DOMDocument'); + + /* Find the EntityDescriptor DOMElement. This should be the first (and only) child of the + * DOMDocument. + */ + $ed = $doc->documentElement; + + if($ed === NULL) { + throw new Exception('Failed to load SAML metadata from empty XML document.'); + } + + if(SimpleSAML_Utilities::isDOMElementOfType($ed, 'EntityDescriptor', '@md') === FALSE) { + throw new Exception('Expected first element in the metadata document to be an EntityDescriptor element.'); + } + + return $ed; + } + + + /** + * This function extracts a list of supported protocols from a SPSSODescriptor or IDPSSODescriptor element. + * + * @param $element The SPSSODescriptor or IDPSSODescriptor element. + * @return Array with the supported protocols. + */ + private static function getSupportedProtocols($element) { + assert('$element instanceof DOMElement'); + + /* The protocolSupportEnumeration is a required attribute. */ + if(!$element->hasAttribute('protocolSupportEnumeration')) { + throw new Exception($element->tagName . ' is missing the required protocolSupportEnumeration attribute.'); + } + + /* The attribute is a space seperated list of supported protocols. */ + $supProt = $element->getAttribute('protocolSupportEnumeration'); + $supProt = explode(' ', $supProt); + + return $supProt; + } + +} + +?> \ No newline at end of file