<?php /** * This is class for parsing of SAML 1.x and SAML 2.0 metadata. * * Metadata is loaded by calling the static methods parseFile, parseString or parseElement. * These functions returns an instance of SimpleSAML_Metadata_SAMLParser. To get metadata * from this object, use the methods getMetadata1xSP or getMetadata20SP. * * To parse a file which can contain a collection of EntityDescriptor or EntitiesDescriptor elements, use the * parseDescriptorsFile, parseDescriptorsString or parseDescriptorsElement methods. These functions will return * an array of SAMLParser elements where each element represents an EntityDescriptor-element. */ class SimpleSAML_Metadata_SAMLParser { /** * This is the list of SAML 1.x protocols. * * @var string[] */ 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. * * @var string[] */ private static $SAML20Protocols = array( 'urn:oasis:names:tc:SAML:2.0:protocol', ); /** * This is the entity id we find in the metadata. * * @var string */ 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 elements from parseSSODescriptor and: * - 'AssertionConsumerService': Array with the SP's assertion consumer services. * Each assertion consumer service is stored as an associative array with the * elements that parseGenericEndpoint returns. * * @var array[] */ private $spDescriptors; /** * This is an array with the processed IDPSSODescriptor elements we have found. * Each element in the array is an associative array with the elements from parseSSODescriptor and: * - 'SingleSignOnService': Array with the IdP's single sign on service endpoints. Each endpoint is stored * as an associative array with the elements that parseGenericEndpoint returns. * * @var array[] */ private $idpDescriptors; /** * List of attribute authorities we have found. * * @var array */ private $attributeAuthorityDescriptors = array(); /** * This is an associative array with the organization name for this entity. The key of * the associative array is the language code, while the value is a string with the * organization name. * * @var string[] */ private $organizationName = array(); /** * This is an associative array with the organization display name for this entity. The key of * the associative array is the language code, while the value is a string with the * organization display name. * * @var string[] */ private $organizationDisplayName = array(); /** * This is an associative array with the organization URI for this entity. The key of * the associative array is the language code, while the value is the URI. * * @var string[] */ private $organizationURL = array(); /** * This is an array of the Contact Persons of this entity. * * @var array[] */ private $contacts = array(); /** * @var array */ private $scopes; /** * @var array */ private $entityAttributes; /** * An associative array of attributes from the RegistrationInfo element. * @var array */ private $registrationInfo; /** * @var array */ private $tags; /** * This is an array of elements that may be used to validate this element. * * @var \SAML2\SignedElementHelper[] */ private $validators = array(); /** * The original EntityDescriptor element for this entity, as a base64 encoded string. * * @var string */ private $entityDescriptor; /** * This is the constructor for the SAMLParser class. * * @param \SAML2\XML\md\EntityDescriptor $entityElement The EntityDescriptor. * @param int|NULL $maxExpireTime The unix timestamp for when this entity should expire, or * NULL if unknown. * @param array $validators An array of parent elements that may validate this element. * @param array $parentExtensions An optional array of extensions from the parent element. */ private function __construct( \SAML2\XML\md\EntityDescriptor $entityElement, $maxExpireTime, array $validators = array(), array $parentExtensions = null ) { assert($maxExpireTime === null || is_int($maxExpireTime)); $this->spDescriptors = array(); $this->idpDescriptors = array(); $e = $entityElement->toXML(); $e = $e->ownerDocument->saveXML($e); $this->entityDescriptor = base64_encode($e); $this->entityId = $entityElement->entityID; $expireTime = self::getExpireTime($entityElement, $maxExpireTime); $this->validators = $validators; $this->validators[] = $entityElement; // process Extensions element, if it exists $ext = self::processExtensions($entityElement, $parentExtensions); $this->scopes = $ext['scope']; $this->tags = $ext['tags']; $this->entityAttributes = $ext['EntityAttributes']; $this->registrationInfo = $ext['RegistrationInfo']; // look over the RoleDescriptors foreach ($entityElement->RoleDescriptor as $child) { if ($child instanceof \SAML2\XML\md\SPSSODescriptor) { $this->processSPSSODescriptor($child, $expireTime); } elseif ($child instanceof \SAML2\XML\md\IDPSSODescriptor) { $this->processIDPSSODescriptor($child, $expireTime); } elseif ($child instanceof \SAML2\XML\md\AttributeAuthorityDescriptor) { $this->processAttributeAuthorityDescriptor($child, $expireTime); } } if ($entityElement->Organization) { $this->processOrganization($entityElement->Organization); } if (!empty($entityElement->ContactPerson)) { foreach ($entityElement->ContactPerson as $contact) { $this->processContactPerson($contact); } } } /** * This function parses a file which contains XML encoded metadata. * * @param string $file The path to the file which contains the metadata. * * @return SimpleSAML_Metadata_SAMLParser An instance of this class with the metadata loaded. * @throws Exception If the file does not parse as XML. */ public static function parseFile($file) { $data = \SimpleSAML\Utils\HTTP::fetch($file); try { $doc = \SAML2\DOMDocumentFactory::fromString($data); } catch(\Exception $e) { throw new Exception('Failed to read XML from file: '.$file); } return self::parseDocument($doc); } /** * This function parses a string which contains XML encoded metadata. * * @param string $metadata A string which contains XML encoded metadata. * * @return SimpleSAML_Metadata_SAMLParser An instance of this class with the metadata loaded. * @throws Exception If the string does not parse as XML. */ public static function parseString($metadata) { try { $doc = \SAML2\DOMDocumentFactory::fromString($metadata); } catch(\Exception $e) { throw new Exception('Failed to parse XML string.'); } return self::parseDocument($doc); } /** * This function parses a DOMDocument which is assumed to contain a single EntityDescriptor element. * * @param DOMDocument $document The DOMDocument which contains the EntityDescriptor element. * * @return SimpleSAML_Metadata_SAMLParser An instance of this class with the metadata loaded. */ public static function parseDocument($document) { assert($document instanceof DOMDocument); $entityElement = self::findEntityDescriptor($document); return self::parseElement($entityElement); } /** * This function parses a \SAML2\XML\md\EntityDescriptor object which represents a EntityDescriptor element. * * @param \SAML2\XML\md\EntityDescriptor $entityElement A \SAML2\XML\md\EntityDescriptor object which represents a * EntityDescriptor element. * * @return SimpleSAML_Metadata_SAMLParser An instance of this class with the metadata loaded. */ public static function parseElement($entityElement) { assert($entityElement instanceof \SAML2\XML\md\EntityDescriptor); return new SimpleSAML_Metadata_SAMLParser($entityElement, null); } /** * This function parses a file where the root node is either an EntityDescriptor element or an * EntitiesDescriptor element. In both cases it will return an associative array of SAMLParser instances. If * the file contains a single EntityDescriptorElement, then the array will contain a single SAMLParser * instance. * * @param string $file The path to the file which contains the EntityDescriptor or EntitiesDescriptor element. * * @return SimpleSAML_Metadata_SAMLParser[] An array of SAMLParser instances. * @throws Exception If the file does not parse as XML. */ public static function parseDescriptorsFile($file) { if ($file === null) { throw new Exception('Cannot open file NULL. File name not specified.'); } $data = \SimpleSAML\Utils\HTTP::fetch($file); try { $doc = \SAML2\DOMDocumentFactory::fromString($data); } catch(\Exception $e) { throw new Exception('Failed to read XML from file: '.$file); } if ($doc->documentElement === null) { throw new Exception('Opened file is not an XML document: '.$file); } return self::parseDescriptorsElement($doc->documentElement); } /** * This function parses a string with XML data. The root node of the XML data is expected to be either an * EntityDescriptor element or an EntitiesDescriptor element. It will return an associative array of * SAMLParser instances. * * @param string $string The string with XML data. * * @return SimpleSAML_Metadata_SAMLParser[] An associative array of SAMLParser instances. The key of the array will * be the entity id. * @throws Exception If the string does not parse as XML. */ public static function parseDescriptorsString($string) { try { $doc = \SAML2\DOMDocumentFactory::fromString($string); } catch(\Exception $e) { throw new Exception('Failed to parse XML string.'); } return self::parseDescriptorsElement($doc->documentElement); } /** * This function parses a DOMElement which represents either an EntityDescriptor element or an * EntitiesDescriptor element. It will return an associative array of SAMLParser instances in both cases. * * @param DOMElement|NULL $element The DOMElement which contains the EntityDescriptor element or the * EntitiesDescriptor element. * * @return SimpleSAML_Metadata_SAMLParser[] An associative array of SAMLParser instances. The key of the array will * be the entity id. * @throws Exception if the document is empty or the root is an unexpected node. */ public static function parseDescriptorsElement(DOMElement $element = null) { if ($element === null) { throw new Exception('Document was empty.'); } if (SimpleSAML\Utils\XML::isDOMNodeOfType($element, 'EntityDescriptor', '@md') === true) { return self::processDescriptorsElement(new \SAML2\XML\md\EntityDescriptor($element)); } elseif (SimpleSAML\Utils\XML::isDOMNodeOfType($element, 'EntitiesDescriptor', '@md') === true) { return self::processDescriptorsElement(new \SAML2\XML\md\EntitiesDescriptor($element)); } else { throw new Exception('Unexpected root node: ['.$element->namespaceURI.']:'.$element->localName); } } /** * * @param \SAML2\XML\md\EntityDescriptor|\SAML2\XML\md\EntitiesDescriptor $element The element we should process. * @param int|NULL $maxExpireTime The maximum expiration time * of the entities. * @param array $validators The parent-elements that may be * signed. * @param array $parentExtensions An optional array of * extensions from the parent element. * * @return SimpleSAML_Metadata_SAMLParser[] Array of SAMLParser instances. */ private static function processDescriptorsElement( $element, $maxExpireTime = null, array $validators = array(), array $parentExtensions = array() ) { assert($maxExpireTime === null || is_int($maxExpireTime)); if ($element instanceof \SAML2\XML\md\EntityDescriptor) { $ret = new SimpleSAML_Metadata_SAMLParser($element, $maxExpireTime, $validators, $parentExtensions); $ret = array($ret->getEntityId() => $ret); /** @var SimpleSAML_Metadata_SAMLParser[] $ret */ return $ret; } assert($element instanceof \SAML2\XML\md\EntitiesDescriptor); $extensions = self::processExtensions($element, $parentExtensions); $expTime = self::getExpireTime($element, $maxExpireTime); $validators[] = $element; $ret = array(); foreach ($element->children as $child) { $ret += self::processDescriptorsElement($child, $expTime, $validators, $extensions); } return $ret; } /** * Determine how long a given element can be cached. * * This function looks for the 'validUntil' attribute to determine * how long a given XML-element is valid. It returns this as a unix timestamp. * * @param mixed $element The element we should determine the expiry time of. * @param int|NULL $maxExpireTime The maximum expiration time. * * @return int The unix timestamp for when the element should expire. Will be NULL if no * limit is set for the element. */ private static function getExpireTime($element, $maxExpireTime) { // validUntil may be null $expire = $element->validUntil; if ($maxExpireTime !== null && ($expire === null || $maxExpireTime < $expire)) { $expire = $maxExpireTime; } return $expire; } /** * This function returns the entity id of this parsed entity. * * @return string The entity id of this parsed entity. */ public function getEntityId() { return $this->entityId; } private function getMetadataCommon() { $ret = array(); $ret['entityid'] = $this->entityId; $ret['entityDescriptor'] = $this->entityDescriptor; // add organizational metadata if (!empty($this->organizationName)) { $ret['description'] = $this->organizationName; $ret['OrganizationName'] = $this->organizationName; } if (!empty($this->organizationDisplayName)) { $ret['name'] = $this->organizationDisplayName; $ret['OrganizationDisplayName'] = $this->organizationDisplayName; } if (!empty($this->organizationURL)) { $ret['url'] = $this->organizationURL; $ret['OrganizationURL'] = $this->organizationURL; } //add contact metadata $ret['contacts'] = $this->contacts; return $ret; } /** * Add data parsed from extensions to metadata. * * @param array &$metadata The metadata that should be updated. * @param array $roleDescriptor The parsed role descriptor. */ private function addExtensions(array &$metadata, array $roleDescriptor) { assert(array_key_exists('scope', $roleDescriptor)); assert(array_key_exists('tags', $roleDescriptor)); $scopes = array_merge($this->scopes, array_diff($roleDescriptor['scope'], $this->scopes)); if (!empty($scopes)) { $metadata['scope'] = $scopes; } $tags = array_merge($this->tags, array_diff($roleDescriptor['tags'], $this->tags)); if (!empty($tags)) { $metadata['tags'] = $tags; } if (!empty($this->registrationInfo)) { $metadata['RegistrationInfo'] = $this->registrationInfo; } if (!empty($this->entityAttributes)) { $metadata['EntityAttributes'] = $this->entityAttributes; // check for entity categories if (SimpleSAML\Utils\Config\Metadata::isHiddenFromDiscovery($metadata)) { $metadata['hide.from.discovery'] = true; } } if (!empty($roleDescriptor['UIInfo'])) { $metadata['UIInfo'] = $roleDescriptor['UIInfo']; } if (!empty($roleDescriptor['DiscoHints'])) { $metadata['DiscoHints'] = $roleDescriptor['DiscoHints']; } } /** * 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. * - 'certData': X509Certificate for entity (if present). * * Metadata must be loaded with one of the parse functions before this function can be called. * * @return array An associative array with metadata or NULL if we are unable to generate metadata for a SAML 1.x SP. */ public function getMetadata1xSP() { $ret = $this->getMetadataCommon(); $ret['metadata-set'] = 'shib13-sp-remote'; // 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]; // add expire time to metadata if (array_key_exists('expire', $spd)) { $ret['expire'] = $spd['expire']; } // find the assertion consumer service endpoints $ret['AssertionConsumerService'] = $spd['AssertionConsumerService']; // add the list of attributes the SP should receive if (array_key_exists('attributes', $spd)) { $ret['attributes'] = $spd['attributes']; } if (array_key_exists('attributes.required', $spd)) { $ret['attributes.required'] = $spd['attributes.required']; } if (array_key_exists('attributes.NameFormat', $spd)) { $ret['attributes.NameFormat'] = $spd['attributes.NameFormat']; } // add name & description if (array_key_exists('name', $spd)) { $ret['name'] = $spd['name']; } if (array_key_exists('description', $spd)) { $ret['description'] = $spd['description']; } // add public keys if (!empty($spd['keys'])) { $ret['keys'] = $spd['keys']; } // add extensions $this->addExtensions($ret, $spd); // prioritize mdui:DisplayName as the name if available if (!empty($ret['UIInfo']['DisplayName'])) { $ret['name'] = $ret['UIInfo']['DisplayName']; } return $ret; } /** * This function returns the metadata for SAML 1.x IdPs 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. * - 'name': Auto generated name for this entity. Currently set to the entity id. * - 'SingleSignOnService': String with the URL of the SSO service which supports the redirect binding. * - 'SingleLogoutService': String with the URL where we should send logout requests/responses. * - 'certData': X509Certificate for entity (if present). * - 'certFingerprint': Fingerprint of the X509Certificate from the metadata. (deprecated) * * Metadata must be loaded with one of the parse functions before this function can be called. * * @return array An associative array with metadata or NULL if we are unable to generate metadata for a SAML 1.x * IdP. */ public function getMetadata1xIdP() { $ret = $this->getMetadataCommon(); $ret['metadata-set'] = 'shib13-idp-remote'; // find IdP information which supports the SAML 1.x protocol $idp = $this->getIdPDescriptors(self::$SAML1xProtocols); if (count($idp) === 0) { return null; } // we currently only look at the first IDP descriptor which supports SAML 1.x $idp = $idp[0]; // fdd expire time to metadata if (array_key_exists('expire', $idp)) { $ret['expire'] = $idp['expire']; } // find the SSO service endpoints $ret['SingleSignOnService'] = $idp['SingleSignOnService']; // find the ArtifactResolutionService endpoint $ret['ArtifactResolutionService'] = $idp['ArtifactResolutionService']; // add public keys if (!empty($idp['keys'])) { $ret['keys'] = $idp['keys']; } // add extensions $this->addExtensions($ret, $idp); // prioritize mdui:DisplayName as the name if available if (!empty($ret['UIInfo']['DisplayName'])) { $ret['name'] = $ret['UIInfo']['DisplayName']; } 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. * - 'certData': X509Certificate for entity (if present). * * Metadata must be loaded with one of the parse functions before this function can be called. * * @return array An associative array with metadata or NULL if we are unable to generate metadata for a SAML 2.x SP. */ public function getMetadata20SP() { $ret = $this->getMetadataCommon(); $ret['metadata-set'] = 'saml20-sp-remote'; // 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]; // add expire time to metadata if (array_key_exists('expire', $spd)) { $ret['expire'] = $spd['expire']; } // find the assertion consumer service endpoints $ret['AssertionConsumerService'] = $spd['AssertionConsumerService']; // find the single logout service endpoint $ret['SingleLogoutService'] = $spd['SingleLogoutService']; // find the NameIDFormat. This may not exist if (count($spd['nameIDFormats']) > 0) { // SimpleSAMLphp currently only supports a single NameIDFormat pr. SP. We use the first one $ret['NameIDFormat'] = $spd['nameIDFormats'][0]; } // add the list of attributes the SP should receive if (array_key_exists('attributes', $spd)) { $ret['attributes'] = $spd['attributes']; } if (array_key_exists('attributes.required', $spd)) { $ret['attributes.required'] = $spd['attributes.required']; } if (array_key_exists('attributes.NameFormat', $spd)) { $ret['attributes.NameFormat'] = $spd['attributes.NameFormat']; } // add name & description if (array_key_exists('name', $spd)) { $ret['name'] = $spd['name']; } if (array_key_exists('description', $spd)) { $ret['description'] = $spd['description']; } // add public keys if (!empty($spd['keys'])) { $ret['keys'] = $spd['keys']; } // add validate.authnrequest if (array_key_exists('AuthnRequestsSigned', $spd)) { $ret['validate.authnrequest'] = $spd['AuthnRequestsSigned']; } // add saml20.sign.assertion if (array_key_exists('WantAssertionsSigned', $spd)) { $ret['saml20.sign.assertion'] = $spd['WantAssertionsSigned']; } // add extensions $this->addExtensions($ret, $spd); // prioritize mdui:DisplayName as the name if available if (!empty($ret['UIInfo']['DisplayName'])) { $ret['name'] = $ret['UIInfo']['DisplayName']; } return $ret; } /** * This function returns the metadata for SAML 2.0 IdPs 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. * - 'name': Auto generated name for this entity. Currently set to the entity id. * - 'SingleSignOnService': String with the URL of the SSO service which supports the redirect binding. * - 'SingleLogoutService': String with the URL where we should send logout requests(/responses). * - 'SingleLogoutServiceResponse': String where we should send logout responses (if this is different from * the 'SingleLogoutService' endpoint. * - 'NameIDFormats': The name ID formats this IdP supports. * - 'certData': X509Certificate for entity (if present). * - 'certFingerprint': Fingerprint of the X509Certificate from the metadata. (deprecated) * * Metadata must be loaded with one of the parse functions before this function can be called. * * @return array An associative array with metadata or NULL if we are unable to generate metadata for a SAML 2.0 * IdP. */ public function getMetadata20IdP() { $ret = $this->getMetadataCommon(); $ret['metadata-set'] = 'saml20-idp-remote'; // find IdP information which supports the SAML 2.0 protocol $idp = $this->getIdPDescriptors(self::$SAML20Protocols); if (count($idp) === 0) { return null; } // we currently only look at the first IDP descriptor which supports SAML 2.0 $idp = $idp[0]; // add expire time to metadata if (array_key_exists('expire', $idp)) { $ret['expire'] = $idp['expire']; } // enable redirect.sign if WantAuthnRequestsSigned is enabled if ($idp['WantAuthnRequestsSigned']) { $ret['sign.authnrequest'] = true; } // find the SSO service endpoint $ret['SingleSignOnService'] = $idp['SingleSignOnService']; // find the single logout service endpoint $ret['SingleLogoutService'] = $idp['SingleLogoutService']; // find the ArtifactResolutionService endpoint $ret['ArtifactResolutionService'] = $idp['ArtifactResolutionService']; // add supported nameIDFormats $ret['NameIDFormats'] = $idp['nameIDFormats']; // add public keys if (!empty($idp['keys'])) { $ret['keys'] = $idp['keys']; } // add extensions $this->addExtensions($ret, $idp); // prioritize mdui:DisplayName as the name if available if (!empty($ret['UIInfo']['DisplayName'])) { $ret['name'] = $ret['UIInfo']['DisplayName']; } return $ret; } /** * Retrieve AttributeAuthorities from the metadata. * * @return array Array of AttributeAuthorityDescriptor entries. */ public function getAttributeAuthorities() { return $this->attributeAuthorityDescriptors; } /** * Parse a RoleDescriptorType element. * * The returned associative array has the following elements: * - 'protocols': Array with the protocols supported. * - 'expire': Timestamp for when this descriptor expires. * - 'keys': Array of associative arrays with the elements from parseKeyDescriptor. * * @param \SAML2\XML\md\RoleDescriptor $element The element we should extract metadata from. * @param int|NULL $expireTime The unix timestamp for when this element should expire, or * NULL if unknown. * * @return array An associative array with metadata we have extracted from this element. */ private static function parseRoleDescriptorType(\SAML2\XML\md\RoleDescriptor $element, $expireTime) { assert($expireTime === null || is_int($expireTime)); $ret = array(); $expireTime = self::getExpireTime($element, $expireTime); if ($expireTime !== null) { // we got an expired timestamp, either from this element or one of the parent elements $ret['expire'] = $expireTime; } $ret['protocols'] = $element->protocolSupportEnumeration; // process KeyDescriptor elements $ret['keys'] = array(); foreach ($element->KeyDescriptor as $kd) { $key = self::parseKeyDescriptor($kd); if ($key !== null) { $ret['keys'][] = $key; } } $ext = self::processExtensions($element); $ret['scope'] = $ext['scope']; $ret['tags'] = $ext['tags']; $ret['EntityAttributes'] = $ext['EntityAttributes']; $ret['UIInfo'] = $ext['UIInfo']; $ret['DiscoHints'] = $ext['DiscoHints']; return $ret; } /** * This function extracts metadata from a SSODescriptor element. * * The returned associative array has the following elements: * - 'protocols': Array with the protocols this SSODescriptor supports. * - 'SingleLogoutService': Array with the single logout service endpoints. Each endpoint is stored * as an associative array with the elements that parseGenericEndpoint returns. * - 'nameIDFormats': The NameIDFormats supported by this SSODescriptor. This may be an empty array. * - 'keys': Array of associative arrays with the elements from parseKeyDescriptor: * * @param \SAML2\XML\md\SSODescriptorType $element The element we should extract metadata from. * @param int|NULL $expireTime The unix timestamp for when this element should expire, or * NULL if unknown. * * @return array An associative array with metadata we have extracted from this element. */ private static function parseSSODescriptor(\SAML2\XML\md\SSODescriptorType $element, $expireTime) { assert($expireTime === null || is_int($expireTime)); $sd = self::parseRoleDescriptorType($element, $expireTime); // find all SingleLogoutService elements $sd['SingleLogoutService'] = self::extractEndpoints($element->SingleLogoutService); // find all ArtifactResolutionService elements $sd['ArtifactResolutionService'] = self::extractEndpoints($element->ArtifactResolutionService); // process NameIDFormat elements $sd['nameIDFormats'] = $element->NameIDFormat; return $sd; } /** * This function extracts metadata from a SPSSODescriptor element. * * @param \SAML2\XML\md\SPSSODescriptor $element The element which should be parsed. * @param int|NULL $expireTime The unix timestamp for when this element should expire, or * NULL if unknown. */ private function processSPSSODescriptor(\SAML2\XML\md\SPSSODescriptor $element, $expireTime) { assert($expireTime === null || is_int($expireTime)); $sp = self::parseSSODescriptor($element, $expireTime); // find all AssertionConsumerService elements $sp['AssertionConsumerService'] = self::extractEndpoints($element->AssertionConsumerService); // find all the attributes and SP name... $attcs = $element->AttributeConsumingService; if (count($attcs) > 0) { self::parseAttributeConsumerService($attcs[0], $sp); } // check AuthnRequestsSigned if ($element->AuthnRequestsSigned !== null) { $sp['AuthnRequestsSigned'] = $element->AuthnRequestsSigned; } // check WantAssertionsSigned if ($element->WantAssertionsSigned !== null) { $sp['WantAssertionsSigned'] = $element->WantAssertionsSigned; } $this->spDescriptors[] = $sp; } /** * This function extracts metadata from a IDPSSODescriptor element. * * @param \SAML2\XML\md\IDPSSODescriptor $element The element which should be parsed. * @param int|NULL $expireTime The unix timestamp for when this element should expire, or * NULL if unknown. */ private function processIDPSSODescriptor(\SAML2\XML\md\IDPSSODescriptor $element, $expireTime) { assert($expireTime === null || is_int($expireTime)); $idp = self::parseSSODescriptor($element, $expireTime); // find all SingleSignOnService elements $idp['SingleSignOnService'] = self::extractEndpoints($element->SingleSignOnService); if ($element->WantAuthnRequestsSigned) { $idp['WantAuthnRequestsSigned'] = true; } else { $idp['WantAuthnRequestsSigned'] = false; } $this->idpDescriptors[] = $idp; } /** * This function extracts metadata from a AttributeAuthorityDescriptor element. * * @param \SAML2\XML\md\AttributeAuthorityDescriptor $element The element which should be parsed. * @param int|NULL $expireTime The unix timestamp for when this element should * expire, or NULL if unknown. */ private function processAttributeAuthorityDescriptor( \SAML2\XML\md\AttributeAuthorityDescriptor $element, $expireTime ) { assert($expireTime === null || is_int($expireTime)); $aad = self::parseRoleDescriptorType($element, $expireTime); $aad['entityid'] = $this->entityId; $aad['metadata-set'] = 'attributeauthority-remote'; $aad['AttributeService'] = self::extractEndpoints($element->AttributeService); $aad['AssertionIDRequestService'] = self::extractEndpoints($element->AssertionIDRequestService); $aad['NameIDFormat'] = $element->NameIDFormat; $this->attributeAuthorityDescriptors[] = $aad; } /** * Parse an Extensions element. Extensions may appear in multiple elements and certain extension may get inherited * from a parent element. * * @param mixed $element The element which contains the Extensions element. * @param array $parentExtensions An optional array of extensions from the parent element. * * @return array An associative array with the extensions parsed. */ private static function processExtensions($element, $parentExtensions = array()) { $ret = array( 'scope' => array(), 'tags' => array(), 'EntityAttributes' => array(), 'RegistrationInfo' => array(), 'UIInfo' => array(), 'DiscoHints' => array(), ); // Some extensions may get inherited from a parent element if (($element instanceof \SAML2\XML\md\EntityDescriptor || $element instanceof \SAML2\XML\md\EntitiesDescriptor) && !empty($parentExtensions['RegistrationInfo'])) { $ret['RegistrationInfo'] = $parentExtensions['RegistrationInfo']; } foreach ($element->Extensions as $e) { if ($e instanceof \SAML2\XML\shibmd\Scope) { $ret['scope'][] = $e->scope; continue; } // Entity Attributes are only allowed at entity level extensions and not at RoleDescriptor level if ($element instanceof \SAML2\XML\md\EntityDescriptor || $element instanceof \SAML2\XML\md\EntitiesDescriptor) { if ($e instanceof \SAML2\XML\mdrpi\RegistrationInfo) { // Registration Authority cannot be overridden (warn only if override attempts to change the value) if (isset($ret['RegistrationInfo']['registrationAuthority']) && $ret['RegistrationInfo']['registrationAuthority'] !== $e->registrationAuthority) { SimpleSAML\Logger::warning('Invalid attempt to override registrationAuthority \'' . $ret['RegistrationInfo']['registrationAuthority'] . "' with '{$e->registrationAuthority}'"); } else { $ret['RegistrationInfo']['registrationAuthority'] = $e->registrationAuthority; } } if ($e instanceof \SAML2\XML\mdattr\EntityAttributes && !empty($e->children)) { foreach ($e->children as $attr) { // only saml:Attribute are currently supported here. The specifications also allows // saml:Assertions, which more complex processing if ($attr instanceof \SAML2\XML\saml\Attribute) { if (empty($attr->Name) || empty($attr->AttributeValue)) { continue; } // attribute names that is not URI is prefixed as this: '{nameformat}name' $name = $attr->Name; if (empty($attr->NameFormat)) { $name = '{'.\SAML2\Constants::NAMEFORMAT_UNSPECIFIED.'}'.$attr->Name; } elseif ($attr->NameFormat !== 'urn:oasis:names:tc:SAML:2.0:attrname-format:uri') { $name = '{'.$attr->NameFormat.'}'.$attr->Name; } $values = array(); foreach ($attr->AttributeValue as $attrvalue) { $values[] = $attrvalue->getString(); } $ret['EntityAttributes'][$name] = $values; } } } } // UIInfo elements are only allowed at RoleDescriptor level extensions if ($element instanceof \SAML2\XML\md\RoleDescriptor) { if ($e instanceof \SAML2\XML\mdui\UIInfo) { $ret['UIInfo']['DisplayName'] = $e->DisplayName; $ret['UIInfo']['Description'] = $e->Description; $ret['UIInfo']['InformationURL'] = $e->InformationURL; $ret['UIInfo']['PrivacyStatementURL'] = $e->PrivacyStatementURL; foreach ($e->Keywords as $uiItem) { if (!($uiItem instanceof \SAML2\XML\mdui\Keywords) || empty($uiItem->Keywords) || empty($uiItem->lang) ) { continue; } $ret['UIInfo']['Keywords'][$uiItem->lang] = $uiItem->Keywords; } foreach ($e->Logo as $uiItem) { if (!($uiItem instanceof \SAML2\XML\mdui\Logo) || empty($uiItem->url) || empty($uiItem->height) || empty($uiItem->width) ) { continue; } $logo = array( 'url' => $uiItem->url, 'height' => $uiItem->height, 'width' => $uiItem->width, ); if (!empty($uiItem->lang)) { $logo['lang'] = $uiItem->lang; } $ret['UIInfo']['Logo'][] = $logo; } } } // DiscoHints elements are only allowed at IDPSSODescriptor level extensions if ($element instanceof \SAML2\XML\md\IDPSSODescriptor) { if ($e instanceof \SAML2\XML\mdui\DiscoHints) { $ret['DiscoHints']['IPHint'] = $e->IPHint; $ret['DiscoHints']['DomainHint'] = $e->DomainHint; $ret['DiscoHints']['GeolocationHint'] = $e->GeolocationHint; } } if (!($e instanceof \SAML2\XML\Chunk)) { continue; } if ($e->localName === 'Attribute' && $e->namespaceURI === \SAML2\Constants::NS_SAML) { $attribute = $e->getXML(); $name = $attribute->getAttribute('Name'); $values = array_map( array('SimpleSAML\Utils\XML', 'getDOMText'), SimpleSAML\Utils\XML::getDOMChildren($attribute, 'AttributeValue', '@saml2') ); if ($name === 'tags') { foreach ($values as $tagname) { if (!empty($tagname)) { $ret['tags'][] = $tagname; } } } } } return $ret; } /** * Parse and process a Organization element. * * @param \SAML2\XML\md\Organization $element The Organization element. */ private function processOrganization(\SAML2\XML\md\Organization $element) { $this->organizationName = $element->OrganizationName; $this->organizationDisplayName = $element->OrganizationDisplayName; $this->organizationURL = $element->OrganizationURL; } /** * Parse and process a ContactPerson element. * * @param \SAML2\XML\md\ContactPerson $element The ContactPerson element. */ private function processContactPerson(\SAML2\XML\md\ContactPerson $element) { $contactPerson = array(); if (!empty($element->contactType)) { $contactPerson['contactType'] = $element->contactType; } if (!empty($element->Company)) { $contactPerson['company'] = $element->Company; } if (!empty($element->GivenName)) { $contactPerson['givenName'] = $element->GivenName; } if (!empty($element->SurName)) { $contactPerson['surName'] = $element->SurName; } if (!empty($element->EmailAddress)) { $contactPerson['emailAddress'] = $element->EmailAddress; } if (!empty($element->TelephoneNumber)) { $contactPerson['telephoneNumber'] = $element->TelephoneNumber; } if (!empty($contactPerson)) { $this->contacts[] = $contactPerson; } } /** * This function parses AttributeConsumerService elements. * * @param \SAML2\XML\md\AttributeConsumingService $element The AttributeConsumingService to parse. * @param array $sp The array with the SP's metadata. */ private static function parseAttributeConsumerService(\SAML2\XML\md\AttributeConsumingService $element, &$sp) { assert(is_array($sp)); $sp['name'] = $element->ServiceName; $sp['description'] = $element->ServiceDescription; $format = null; $sp['attributes'] = array(); $sp['attributes.required'] = array(); foreach ($element->RequestedAttribute as $child) { $attrname = $child->Name; $sp['attributes'][] = $attrname; if ($child->isRequired !== null && $child->isRequired === true) { $sp['attributes.required'][] = $attrname; } if ($child->NameFormat !== null) { $attrformat = $child->NameFormat; } else { $attrformat = \SAML2\Constants::NAMEFORMAT_UNSPECIFIED; } if ($format === null) { $format = $attrformat; } elseif ($format !== $attrformat) { $format = \SAML2\Constants::NAMEFORMAT_UNSPECIFIED; } } if (empty($sp['attributes'])) { // a really invalid configuration: all AttributeConsumingServices should have one or more attributes unset($sp['attributes']); } if (empty($sp['attributes.required'])) { unset($sp['attributes.required']); } if ($format !== \SAML2\Constants::NAMEFORMAT_UNSPECIFIED && $format !== null) { $sp['attributes.NameFormat'] = $format; } } /** * 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 \SAML2\XML\md\EndpointType $element The element which should be parsed. * * @return array An associative array with the data we have extracted from the element. */ private static function parseGenericEndpoint(\SAML2\XML\md\EndpointType $element) { $ep = array(); $ep['Binding'] = $element->Binding; $ep['Location'] = $element->Location; if ($element->ResponseLocation !== null) { $ep['ResponseLocation'] = $element->ResponseLocation; } if ($element instanceof \SAML2\XML\md\IndexedEndpointType) { $ep['index'] = $element->index; if ($element->isDefault !== null) { $ep['isDefault'] = $element->isDefault; } } return $ep; } /** * Extract generic endpoints. * * @param array $endpoints The endpoints we should parse. * * @return array Array of parsed endpoints. */ private static function extractEndpoints(array $endpoints) { $ret = array(); foreach ($endpoints as $ep) { $ret[] = self::parseGenericEndpoint($ep); } return $ret; } /** * This function parses a KeyDescriptor element. It currently only supports keys with a single * X509 certificate. * * The associative array for a key can contain: * - 'encryption': Indicates whether this key can be used for encryption. * - 'signing': Indicates whether this key can be used for signing. * - 'type: The type of the key. 'X509Certificate' is the only key type we support. * - 'X509Certificate': The contents of the first X509Certificate element (if the type is 'X509Certificate '). * * @param \SAML2\XML\md\KeyDescriptor $kd The KeyDescriptor element. * * @return array|null An associative array describing the key, or null if this is an unsupported key. */ private static function parseKeyDescriptor(\SAML2\XML\md\KeyDescriptor $kd) { $r = array(); if ($kd->use === 'encryption') { $r['encryption'] = true; $r['signing'] = false; } elseif ($kd->use === 'signing') { $r['encryption'] = false; $r['signing'] = true; } else { $r['encryption'] = true; $r['signing'] = true; } $keyInfo = $kd->KeyInfo; foreach ($keyInfo->info as $i) { if ($i instanceof \SAML2\XML\ds\X509Data) { foreach ($i->data as $d) { if ($d instanceof \SAML2\XML\ds\X509Certificate) { $r['type'] = 'X509Certificate'; $r['X509Certificate'] = $d->certificate; return $r; } } } } 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 finds IdP descriptors which supports one of the given protocols. * * @param $protocols Array with the protocols we accept. * * @return Array with IdP descriptors which supports one of the given protocols. */ private function getIdPDescriptors($protocols) { assert(is_array($protocols)); $ret = array(); foreach ($this->idpDescriptors as $idpd) { $sharedProtocols = array_intersect($protocols, $idpd['protocols']); if (count($sharedProtocols) > 0) { $ret[] = $idpd; } } 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 DOMDocument $doc The DOMDocument where we should find the EntityDescriptor node. * * @return \SAML2\XML\md\EntityDescriptor The DOMEntity which represents the EntityDescriptor. * @throws Exception If the document is empty or the first element is not an EntityDescriptor element. */ 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\Utils\XML::isDOMNodeOfType($ed, 'EntityDescriptor', '@md') === false) { throw new Exception('Expected first element in the metadata document to be an EntityDescriptor element.'); } return new \SAML2\XML\md\EntityDescriptor($ed); } /** * If this EntityDescriptor was signed this function use the public key to check the signature. * * @param array $certificates One ore more certificates with the public key. This makes it possible * to do a key rollover. * * @return boolean True if it is possible to check the signature with the certificate, false otherwise. * @throws Exception If the certificate file cannot be found. */ public function validateSignature($certificates) { foreach ($certificates as $cert) { assert(is_string($cert)); $certFile = \SimpleSAML\Utils\Config::getCertPath($cert); if (!file_exists($certFile)) { throw new Exception( 'Could not find certificate file ['.$certFile.'], which is needed to validate signature' ); } $certData = file_get_contents($certFile); foreach ($this->validators as $validator) { $key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA1, array('type' => 'public')); $key->loadKey($certData); try { if ($validator->validate($key)) { return true; } } catch (Exception $e) { // this certificate did not sign this element, skip } } } SimpleSAML\Logger::debug('Could not validate signature'); return false; } /** * This function checks if this EntityDescriptor was signed with a certificate with the * given fingerprint. * * @param string $fingerprint Fingerprint of the certificate which should have been used to sign this * EntityDescriptor. * * @return boolean True if it was signed with the certificate with the given fingerprint, false otherwise. */ public function validateFingerprint($fingerprint) { assert(is_string($fingerprint)); $fingerprint = strtolower(str_replace(":", "", $fingerprint)); $candidates = array(); foreach ($this->validators as $validator) { foreach ($validator->getValidatingCertificates() as $cert) { $fp = strtolower(sha1(base64_decode($cert))); $candidates[] = $fp; if ($fp === $fingerprint) { return true; } } } SimpleSAML\Logger::debug('Fingerprint was ['.$fingerprint.'] not one of ['.join(', ', $candidates).']'); return false; } }